// The grid: objects on tiles. `x` grows right, `y` grows down, and any number of // objects may share a tile. export type ObjectId = number; /** A level in the course's level format (`course/schema/level.schema.json`). */ export interface LevelDef { width: number; height: number; objects: LevelObject[]; /** Module 10, read only by SpellGrid: minions in play, each on its tile. */ cards?: LevelCard[]; /** Module 10, read only by SpellGrid: the cards in player 0's hand. */ hand?: string[]; } /** A minion card on the grid (Module 10). */ export interface LevelCard { card: string; x: number; y: number; } export interface LevelObject { kind: string; x: number; y: number; } /** * What an object is: a thing named by a noun (kind `""`, the noun in lower case), * or a piece of text carrying one word (kind `"text:"`). */ export type Kind = { type: "thing"; noun: string } | { type: "text"; word: string }; const TEXT_PREFIX = "text:"; export const Kind = { parse(kind: string): Kind { return kind.startsWith(TEXT_PREFIX) ? { type: "text", word: kind.slice(TEXT_PREFIX.length).toUpperCase() } : { type: "thing", noun: kind.toUpperCase() }; }, /** The kind as it is placed: `""` in lower case, or `"text:"`. */ format(kind: Kind): string { return kind.type === "text" ? TEXT_PREFIX + kind.word : kind.noun.toLowerCase(); }, }; export interface Obj { id: ObjectId; kind: Kind; x: number; y: number; } /** What `Board.restore` puts back (Module 9, undo): every object, oldest first, and the id counter. */ export interface BoardState { objects: Obj[]; nextId: ObjectId; } export class Board { private readonly objectsById = new Map(); private nextId = 0; constructor( readonly width: number, readonly height: number, ) {} inBounds(x: number, y: number): boolean { return x >= 0 && x < this.width && y >= 0 && y < this.height; } /** Add an object; ids start at 1 and are never reused. Throws off the board. */ place(kind: Kind, x: number, y: number): ObjectId { if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`); const id = ++this.nextId; this.objectsById.set(id, { id, kind, x, y }); return id; } /** * An id that no object will ever have (Module 10): SpellGrid gives one to each * card that isn't on the grid, so cards and objects share one id space. */ reserveId(): ObjectId { return ++this.nextId; } /** The object `id`. Throws if there is none. */ get(id: ObjectId): Obj { const o = this.objectsById.get(id); if (o === undefined) throw new Error(`no object ${id}`); return o; } /** The object `id`, or `undefined` once it has been removed. Throws on an id this board never issued. */ find(id: ObjectId): Obj | undefined { if (!(id >= 1 && id <= this.nextId)) throw new Error(`no object ${id}`); return this.objectsById.get(id); } /** Take object `id` off the board for good. */ remove(id: ObjectId): void { this.objectsById.delete(id); } /** Move object `id` by `[dx, dy]`. Throws if it would leave the board. */ shift(id: ObjectId, [dx, dy]: readonly [number, number]): void { const o = this.get(id); const [x, y] = [o.x + dx, o.y + dy]; if (!this.inBounds(x, y)) throw new Error(`(${x}, ${y}) is outside the ${this.width}x${this.height} level`); o.x = x; o.y = y; } /** Make object `id` a `kind`, where it stands. Throws if there is no such object. */ setKind(id: ObjectId, kind: Kind): void { this.get(id).kind = kind; } /** The objects on tile (x, y), oldest first. */ at(x: number, y: number): Obj[] { return this.objects().filter((o) => o.x === x && o.y === y); } /** Every object, oldest first. */ objects(): Obj[] { return [...this.objectsById.values()]; } /** A copy of everything the board holds (Module 9, undo). */ snapshot(): BoardState { return { objects: this.objects().map((o) => ({ ...o })), nextId: this.nextId }; } /** Put the board back as `state` recorded it: the same objects, ids, ages and id counter. */ restore(state: BoardState): void { this.objectsById.clear(); for (const o of state.objects) this.objectsById.set(o.id, { ...o }); this.nextId = state.nextId; } }