import Phaser from 'phaser'; import { UI, font, shake } from './style'; // The stamp is the whole game in one gesture, so the timing is doing the acting: // a wind-up above the target, 160ms of accelerating travel, a hard hit, then a // 90ms dead hang before the lift. The hang is what makes it feel like a decision // rather than an animation — do not shorten it. const HEAD_W = 54; const HEAD_H = 22; const TRAVEL_MS = 160; const HANG_MS = 90; const RELEASE_MS = 180; const RING_MS = 200; const DUST_MS = 350; const DUST_COUNT = 9; const SPLATS = 7; /** Head sits above the decal; ring and dust sit between them. */ const RING_DEPTH_OFFSET = 5; const HEAD_DEPTH_OFFSET = 10; export interface StampSfx { stampSlam(): void; stampInk(): void; } export interface StampHandle { /** The ink mark left behind. The caller owns it; destroy it when the patron leaves. */ decal: Phaser.GameObjects.Container; } export interface StampOptions { /** Always scene/world coordinates — converted into `parent` space when one is given. */ x: number; y: number; text?: string; colour?: number; depth?: number; sfx?: StampSfx; /** Parent for the decal, so the mark travels with the patron. Defaults to the scene root. */ parent?: Phaser.GameObjects.Container; /** Per-impression variation. Same seed => same mark. Defaults to a hash of `text`. */ seed?: number; onSlam?: () => void; onDone?: () => void; } /** * FNV-1a over the label so DENIED and ADMITTED get different splatter, stably; the * caller's seed is folded in after so two DENIEDs are not the same printed mess. */ function hashText(text: string, seed?: number): number { let h = 2166136261; for (const ch of text) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619); } if (seed !== undefined) { let s = seed >>> 0; for (let i = 0; i < 4; i++) { h ^= s & 0xff; h = Math.imul(h, 16777619); s >>>= 8; } } return (h >>> 0) % 4096; } /** Hash noise in place of Math.random — same label always prints the same mess. */ function sprinkle(seed: number, i: number): number { const s = Math.sin(seed * 12.9898 + i * 78.233) * 43758.5453; return s - Math.floor(s); } /** The rubber head: dark body, inked face, knurled grip on top. */ function buildHead( scene: Phaser.Scene, colour: number, label: string, ): Phaser.GameObjects.Container { const head = scene.add.container(0, 0); const body = scene.add.graphics(); body.fillStyle(UI.ink, 1); body.fillRoundedRect(-HEAD_W / 2, -HEAD_H / 2, HEAD_W, HEAD_H, 3); body.fillStyle(UI.panelLip, 1); body.fillRect(-10, -HEAD_H / 2 - 8, 20, 8); body.fillStyle(UI.panel, 1); body.fillRect(-14, -HEAD_H / 2 - 11, 28, 4); const face = scene.add.graphics(); face.fillStyle(colour, 1); face.fillRoundedRect(-HEAD_W / 2 + 3, -HEAD_H / 2 + 4, HEAD_W - 6, HEAD_H - 7, 2); const text = scene.add.text(0, 1, label, font(8, UI.paper)).setOrigin(0.5).setLetterSpacing(1); head.add([body, face, text]); return head; } /** The mark that outlives the gesture: border, label, and uneven ink. */ function fillDecal( scene: Phaser.Scene, decal: Phaser.GameObjects.Container, colour: number, label: string, seed: number, ): void { // Never quite square to the card — a machine would print it straight. decal.setRotation((sprinkle(seed, 0) - 0.5) * 0.16); const border = scene.add.graphics(); border.lineStyle(2, colour, 0.85); border.strokeRoundedRect(-HEAD_W / 2 + 2, -HEAD_H / 2 + 3, HEAD_W - 4, HEAD_H - 6, 2); decal.add(border); // Smudge under the crisp pass: one bad impression, printed twice. decal.add( scene.add.text(1, 2, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.35), ); decal.add(scene.add.text(0, 1, label, font(9, colour)).setOrigin(0.5).setLetterSpacing(1).setAlpha(0.92)); // Over-inked pooling near the edges plus a few thrown specks. for (let i = 0; i < SPLATS; i++) { const a = (i / SPLATS) * Math.PI * 2 + sprinkle(seed, 100 + i); const r = 12 + sprinkle(seed, 200 + i) * 16; const size = 1 + Math.round(sprinkle(seed, 300 + i) * 2); decal.add( scene.add .circle(Math.cos(a) * r, Math.sin(a) * r * 0.5, size, colour, 1) .setAlpha(0.28 + sprinkle(seed, 400 + i) * 0.35), ); } } /** Shockwave: a flattened ring, because the ink lands on a surface, not in air. */ function spawnRing(scene: Phaser.Scene, opts: StampOptions, colour: number, depth: number): void { const ring = scene.add .circle(opts.x, opts.y, 10, colour, 0) .setStrokeStyle(2, colour, 0.9) .setDepth(depth) .setScale(0.4, 0.25); scene.tweens.add({ targets: ring, scaleX: 2.4, scaleY: 1.3, alpha: 0, duration: RING_MS, ease: 'Cubic.easeOut', onComplete: () => ring.destroy(), }); } /** Fixed angular steps — a random spray reads as noise, an even ring reads as force. */ function spawnDust(scene: Phaser.Scene, opts: StampOptions, colour: number, seed: number, depth: number): void { for (let i = 0; i < DUST_COUNT; i++) { const a = (i / DUST_COUNT) * Math.PI * 2; const dist = 13 + sprinkle(seed, 500 + i) * 11; const size = 1 + Math.round(sprinkle(seed, 600 + i)); // Alternating grit and ink so the burst reads as both dust and spatter. const tint = i % 2 === 0 ? UI.paper : colour; const p = scene.add .rectangle(opts.x, opts.y, size, size, tint, 0.75) .setDepth(depth); scene.tweens.add({ targets: p, x: opts.x + Math.cos(a) * dist, y: opts.y + Math.sin(a) * dist * 0.45 + 4, alpha: 0, duration: DUST_MS, ease: 'Quad.easeOut', onComplete: () => p.destroy(), }); } } /** * Slam a stamp onto world (x, y). The head, ring and dust clean themselves up; the * returned decal is the caller's to destroy — nothing else will. It is empty until * the head lands, so the returned container is safe to reparent or destroy early. * Colour and label are pure inputs — the ADMITTED variant is the same code path. */ export function stamp(scene: Phaser.Scene, opts: StampOptions): StampHandle { const label = opts.text ?? 'DENIED'; const colour = opts.colour ?? UI.danger; const depth = opts.depth ?? 0; const seed = hashText(label, opts.seed); const decal = scene.add.container(0, 0).setDepth(depth); if (opts.parent) opts.parent.add(decal); let decalAlive = true; decal.once(Phaser.GameObjects.Events.DESTROY, () => { decalAlive = false; }); const head = buildHead(scene, colour, label); head .setDepth(depth + HEAD_DEPTH_OFFSET) .setPosition(opts.x + 14, opts.y - 70) .setScale(1.6) .setRotation(-0.35) .setAlpha(0.35); const impact = (): void => { shake(scene, 4, 120); opts.sfx?.stampSlam(); opts.sfx?.stampInk(); opts.onSlam?.(); if (decalAlive) { // Placed at impact, not at call time, so a moving parent stamps where the ink hit. const local = opts.parent ? opts.parent.getWorldTransformMatrix().applyInverse(opts.x, opts.y) : { x: opts.x, y: opts.y }; decal.setPosition(local.x, local.y); fillDecal(scene, decal, colour, label, seed); } spawnRing(scene, opts, colour, depth + RING_DEPTH_OFFSET); spawnDust(scene, opts, colour, seed, depth + RING_DEPTH_OFFSET); // `delay` is the hang — nothing moves, and that stillness is the punctuation. scene.tweens.add({ targets: head, y: opts.y - 26, alpha: 0, duration: RELEASE_MS, delay: HANG_MS, ease: 'Quad.easeOut', onComplete: () => { head.destroy(); opts.onDone?.(); }, }); }; // Split easings per axis: the horizontal settles early while the vertical is // still accelerating, which bends the path into an arc without a path object. scene.tweens.add({ targets: head, x: opts.x, duration: TRAVEL_MS, ease: 'Quad.easeOut', }); scene.tweens.add({ targets: head, alpha: 1, duration: TRAVEL_MS * 0.6, ease: 'Linear', }); scene.tweens.add({ targets: head, y: opts.y, rotation: 0, scaleX: 1, scaleY: 1, duration: TRAVEL_MS, ease: 'Back.easeIn', onComplete: impact, }); return { decal }; }