/** * Molehill — the mouse is the whole game. * * No character body, no keyboard, no gravity: nine holes, a mole that pops out * of one of them, and a cursor. The engine's own `Clickable` turns each mole * into a node that reports being clicked, the built-in `ScoreKeeper` holds the * score and the misses, and the wiring between them is scene JSON. * * This is the starter for an entire family the other templates do not cover — * match-3, tower defense, cards, point-and-click, RTS, board games, idle — all * of which are played by pointing at things. */ import type { UiBanner, UiButton, UiText } from 'incanto'; import { Behavior, type Node } from 'incanto'; /** Holes across the field, mirrored by `game.scene.json`. */ const HOLES = 9; const MOLES_TO_WIN = 12; const MISSES_ALLOWED = 3; /** A mole node, as this game drives it. */ interface Mole extends Node { visible: boolean; color: string; scale: number[]; } const UP = '#a9714b'; const HIT = '#ffd166'; /** * The field: pops a mole, waits, and calls it a miss if you were too slow. * * It emits rather than reaches — `whacked` and `escaped` are wired in the scene * to `ScoreKeeper.addScore` / `loseLife` and to the two `AudioPlayer`s, so the * scoring, the sound and the game-over all live in JSON. */ export class Molehill extends Behavior { static readonly props = { /** Seconds a mole stays up at the start. */ upSeconds: { default: 1.4 }, /** Seconds between one mole going away and the next arriving. */ gapSeconds: { default: 0.55 }, /** Every mole is this much quicker than the one before (0 = no ramp). */ quicken: { default: 0.04 }, }; static readonly signals: readonly string[] = ['whacked', 'escaped']; upSeconds = 1.4; gapSeconds = 0.55; quicken = 0.04; /** The mole that is up right now, or null between moles. */ private up: Mole | null = null; private timer = 0; private popped = 0; private running = true; /** The mole showing its hit colour, and how long it has left to show it. */ private flashing: Mole | null = null; private flash = 0; override onReady(): void { this.timer = this.gapSeconds; for (const mole of this.moles()) mole.visible = false; } /** Freeze the field — wired to the ScoreKeeper's `won` and `lost`. */ stop(): void { this.running = false; this.hide(); } /** Start over, from the button on the HUD. */ restart(): void { this.running = true; this.popped = 0; this.timer = this.gapSeconds; this.hide(); } override update(dt: number): void { if (this.flashing) { this.flash -= dt; if (this.flash <= 0) { this.flashing.color = UP; this.flashing.visible = false; this.flashing = null; } } if (!this.running) return; this.timer -= dt; if (this.timer > 0) return; if (this.up) { // Its time ran out with nobody clicking it — that is the miss. this.hide(); this.timer = this.gapSeconds; this.emit('escaped'); return; } this.pop(); } onMoleClicked(mole: Node): void { // Only the mole that is UP counts: a click on one already going down is a // click on nothing, which is what the player sees. if (!this.running || mole !== (this.up as unknown as Node)) return; // It flashes before it goes, so a hit reads as a hit rather than as the // mole simply vanishing. const hit = this.up; if (!hit) return; hit.color = HIT; this.flashing = hit; this.flash = 0.09; this.up = null; this.timer = this.gapSeconds; this.emit('whacked', 1); } private pop(): void { const moles = this.moles(); if (moles.length === 0) return; const next = moles[Math.floor(this.engine.rng.next() * moles.length)]; if (!next) return; next.visible = true; next.color = UP; this.up = next; this.popped += 1; // Quicker every time, so a run that goes long still ends. this.timer = Math.max(0.35, this.upSeconds - this.popped * this.quicken); } private hide(): void { if (this.up) this.up.visible = false; if (this.flashing) { this.flashing.color = UP; this.flashing.visible = false; this.flashing = null; } this.up = null; } private moles(): Mole[] { const out: Mole[] = []; for (const hole of this.node.children) { const mole = hole.getNodeOrNull('Mole'); if (mole) out.push(mole as unknown as Mole); } return out; } /** How many holes this field found — read by the harness. */ get holeCount(): number { return this.node.children.length; } } /** The HUD, driven entirely by the ScoreKeeper's signals. */ export class Scoreboard extends Behavior { private text(name: string): UiText { return this.node.getNode(name) as unknown as UiText; } onScore(score: number): void { this.text('Score').text = `Moles ${score} / ${MOLES_TO_WIN}`; } onLives(lives: number): void { this.text('Lives').text = `Misses left ${lives}`; } onWon(): void { this.finish(`ALL ${MOLES_TO_WIN} WHACKED`, '#ffd166'); } onLost(): void { this.finish('THEY GOT AWAY', '#ef4444'); } onAgain(): void { const game = this.node.getNode('/root'); (game.behavior as unknown as { setScore(n: number): void; lives: number }).setScore(0); (game.behavior as unknown as { lives: number }).lives = MISSES_ALLOWED; (this.node.getNode('/root/Field').behavior as unknown as { restart(): void }).restart(); (this.node.getNode('Banner') as unknown as UiBanner).clear(); (this.node.getNode('Again') as unknown as UiButton).visible = false; this.onScore(0); this.onLives(MISSES_ALLOWED); } private finish(text: string, color: string): void { (this.node.getNode('Banner') as unknown as UiBanner).show(text, { color, seconds: 0 }); (this.node.getNode('Again') as unknown as UiButton).visible = true; } } /** Holes the field expects, for a harness that wants to check the board. */ export const FIELD_SIZE = HOLES;