import Phaser from 'phaser'; import { CONTRABAND } from '../../../data/contraband'; import type { Patron } from '../../../data/types'; import { PATDOWN_INTRO } from '../../../data/strings/floor'; import { PATDOWN_MS, POCKET_COUNT, foundLine, scorePatDown } from '../patDown'; import type { PatDownResult, Pocket } from '../patDown'; const VIEW_W = 640; const VIEW_H = 360; const DEPTH = 5000; /** patDown.ts lays pockets out on a 64x96 outline; blow it up so 12px zones are clickable. */ const SCALE = 2.5; const OUTLINE_X = 46; const OUTLINE_Y = 62; const ZONE = 24; const BIN_X = 430; const BIN_Y = 190; const BIN_W = 160; const BIN_H = 170; const PINK = 0xd03470; const GREEN = 0x9fe8a0; const AMBER = 0xd8a020; const PURPLE = 0x7b2fd0; const MONO = 'monospace'; interface TokenArt { readonly w: number; readonly h: number; readonly colour: number; } /** Placeholder art, one entry per CONTRABAND spriteHint. Real sprites land in the v0.4 pass. */ const TOKEN_ART: Readonly> = { baggie: { w: 14, h: 10, colour: 0xf2f2f0 }, flask: { w: 12, h: 22, colour: 0xb8c0c8 }, goonSack: { w: 24, h: 16, colour: 0xc8ccd4 }, tinnies: { w: 18, h: 20, colour: AMBER }, kebab: { w: 12, h: 26, colour: 0xd8cea4 }, someoneElsesId: { w: 22, h: 14, colour: 0xdad4c4 }, budgie: { w: 18, h: 14, colour: 0x6fd06f }, vapes: { w: 22, h: 12, colour: PURPLE }, marker: { w: 8, h: 24, colour: 0x24242c }, snags: { w: 24, h: 12, colour: 0xe08c94 }, }; const DEFAULT_ART: TokenArt = { w: 16, h: 14, colour: 0x9aa0b0 }; const NAME_BY_ID = new Map(CONTRABAND.map((c) => [c.id, c.name] as const)); interface ZoneView { readonly g: Phaser.GameObjects.Graphics; readonly label: Phaser.GameObjects.Text; readonly hit: Phaser.GameObjects.Zone; } interface TokenView { readonly root: Phaser.GameObjects.Container; readonly body: Phaser.GameObjects.Rectangle; readonly label: Phaser.GameObjects.Text; pocketIndex: number; itemId: string; homeX: number; homeY: number; grabOffX: number; grabOffY: number; /** Owns a pocket's item this round. One token per pocket, so slots are never recycled mid-round. */ live: boolean; /** In or past the bin tween: no longer draggable, no longer flapping. */ gone: boolean; } // Dashes along each edge, corners left open — reads as a rounded dashed hint box // without paying for an actual rounded path per zone. function dashRect(g: Phaser.GameObjects.Graphics, x: number, y: number, w: number, h: number): void { const step = 6; const dash = 3; const r = 3; for (let px = x + r; px < x + w - r; px += step) { const len = Math.min(dash, x + w - r - px); g.lineBetween(px, y, px + len, y); g.lineBetween(px, y + h, px + len, y + h); } for (let py = y + r; py < y + h - r; py += step) { const len = Math.min(dash, y + h - r - py); g.lineBetween(x, py, x, py + len); g.lineBetween(x + w, py, x + w, py + len); } } /** * The timed pat-down. Thin shell: all scoring lives in patDown.ts — this only * flips `revealed` / `binned` on the caller's pockets, which is the agreed channel. */ export class PatDownOverlay { private readonly scene: Phaser.Scene; private readonly root: Phaser.GameObjects.Container; private readonly timerBar: Phaser.GameObjects.Rectangle; private readonly timerText: Phaser.GameObjects.Text; private readonly lineText: Phaser.GameObjects.Text; private readonly logText: Phaser.GameObjects.Text; private readonly binGeom = new Phaser.Geom.Rectangle(BIN_X - BIN_W / 2, BIN_Y - BIN_H / 2, BIN_W, BIN_H); private readonly zones: ZoneView[] = []; private readonly tokens: TokenView[] = []; private pockets: Pocket[] = []; private patron: Patron | null = null; private doneFn: ((r: PatDownResult) => void) | null = null; private remainingMs = 0; private flapMs = 0; private open_ = false; private found: string[] = []; constructor(scene: Phaser.Scene) { this.scene = scene; this.root = scene.add.container(0, 0); this.root.add(scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, VIEW_W, VIEW_H, 0x05050a, 0.82)); this.root.add( scene.add.rectangle(VIEW_W / 2, VIEW_H / 2, 600, 328, 0x0d0d16, 0.96).setStrokeStyle(1, PINK, 0.6), ); this.root.add(scene.add.rectangle(32, 30, 576, 8, 0x1c1c2a).setOrigin(0, 0)); this.timerBar = scene.add.rectangle(32, 30, 576, 8, GREEN).setOrigin(0, 0); this.root.add(this.timerBar); this.root.add(scene.add.text(32, 44, PATDOWN_INTRO, { fontFamily: MONO, fontSize: '9px', color: '#d03470' })); this.timerText = scene.add .text(608, 44, '', { fontFamily: MONO, fontSize: '9px', color: '#9fe8a0' }) .setOrigin(1, 0); this.root.add(this.timerText); this.buildOutline(); this.buildBin(); for (let i = 0; i < POCKET_COUNT; i++) this.zones.push(this.buildZone(i)); for (let i = 0; i < POCKET_COUNT; i++) this.tokens.push(this.buildToken()); this.logText = scene.add.text(524, 104, '', { fontFamily: MONO, fontSize: '7px', color: '#d8a020', wordWrap: { width: 88 }, lineSpacing: 2, }); this.root.add(this.logText); this.lineText = scene.add.text(46, 316, '', { fontFamily: MONO, fontSize: '8px', color: '#e8e4d8', wordWrap: { width: 390 }, }); this.root.add(this.lineText); this.root.add( scene.add .text(608, 318, 'CLICK pockets · DRAG to bin · ESC done', { fontFamily: MONO, fontSize: '7px', color: '#556', }) .setOrigin(1, 0), ); this.root.setDepth(DEPTH).setScrollFactor(0, 0, true).setVisible(false); scene.input.on('dragstart', this.onDragStart); scene.input.on('drag', this.onDrag); scene.input.on('dragend', this.onDragEnd); } get isOpen(): boolean { return this.open_; } open(args: { patron: Patron; pockets: Pocket[] }, done: (r: PatDownResult) => void): void { if (this.open_) return; this.patron = args.patron; this.pockets = args.pockets; this.doneFn = done; this.remainingMs = PATDOWN_MS; this.flapMs = 0; this.found = []; this.open_ = true; this.lineText.setText(''); this.logText.setText(''); for (let i = 0; i < this.zones.length; i++) { const z = this.zones[i]; const p = this.pockets[i]; if (z === undefined) continue; const on = p !== undefined; z.g.setVisible(on); z.label.setVisible(on); z.hit.setVisible(on); if (z.hit.input) z.hit.input.enabled = on; if (on) this.paintZone(i); } for (const t of this.tokens) this.hideToken(t); this.root.setVisible(true); this.tick(); } update(deltaMs: number): void { if (!this.open_) return; this.flapMs += deltaMs; this.remainingMs -= deltaMs; for (const t of this.tokens) { // The budgie is alive and it would like everyone to know. if (t.live && !t.gone && t.itemId === 'budgie') t.root.setRotation(Math.sin(this.flapMs / 70) * 0.3); } if (this.remainingMs <= 0) { this.remainingMs = 0; this.tick(); this.resolve(); return; } this.tick(); } handleKey(key: string): void { if (!this.open_) return; // The scene uppercases before routing, so 'Escape' never matched and ESC was // dead here while the on-screen hint promised it worked. const k = key.toUpperCase(); if (k === 'ESC' || k === 'ESCAPE') this.resolve(); } destroy(): void { this.scene.input.off('dragstart', this.onDragStart); this.scene.input.off('drag', this.onDrag); this.scene.input.off('dragend', this.onDragEnd); this.root.destroy(true); this.doneFn = null; this.open_ = false; } // ---- build (constructor only) ---- private buildOutline(): void { const g = this.scene.add.graphics(); const px = (v: number): number => OUTLINE_X + v * SCALE; const py = (v: number): number => OUTLINE_Y + v * SCALE; const box = (x: number, y: number, w: number, h: number): void => { g.fillRect(px(x), py(y), w * SCALE, h * SCALE); g.strokeRect(px(x), py(y), w * SCALE, h * SCALE); }; g.fillStyle(0x14141f, 1); g.lineStyle(1, 0x3c3c5a, 1); g.fillCircle(px(32), py(10), 8 * SCALE); g.strokeCircle(px(32), py(10), 8 * SCALE); box(30, 17, 4, 4); // neck box(20, 21, 24, 29); // torso box(2, 24, 18, 5); // arms out, mate box(44, 24, 18, 5); box(20, 50, 24, 10); // hips box(21, 60, 9, 34); // legs box(34, 60, 9, 34); this.root.add(g); } private buildBin(): void { const s = this.scene; this.root.add( s.add.rectangle(BIN_X, BIN_Y, BIN_W, BIN_H, 0x120c1c, 0.9).setStrokeStyle(1, GREEN, 0.75), ); this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2, BIN_W + 12, 12, GREEN, 0.22)); this.root.add(s.add.rectangle(BIN_X, BIN_Y - BIN_H / 2 + 2, 90, 4, 0x05050a)); // slot this.root.add( s.add .text(BIN_X, BIN_Y - BIN_H / 2 + 18, 'AMNESTY BIN', { fontFamily: MONO, fontSize: '9px', color: '#9fe8a0', }) .setOrigin(0.5, 0), ); this.root.add( s.add .text(BIN_X, BIN_Y + BIN_H / 2 - 16, 'no questions asked', { fontFamily: MONO, fontSize: '7px', color: '#4a6a50', }) .setOrigin(0.5, 0), ); } private buildZone(index: number): ZoneView { const s = this.scene; const g = s.add.graphics(); const label = s.add.text(0, 0, '', { fontFamily: MONO, fontSize: '6px', color: '#6f7aa8' }).setOrigin(0.5, 0); const hit = s.add.zone(0, 0, ZONE + 8, ZONE + 8).setInteractive({ useHandCursor: true }); hit.on('pointerdown', () => this.revealPocket(index)); this.root.add([g, label, hit]); return { g, label, hit }; } private buildToken(): TokenView { const s = this.scene; const root = s.add.container(0, 0); const body = s.add.rectangle(0, 0, 16, 14, 0xffffff).setStrokeStyle(1, 0x05050a, 0.8); const label = s.add .text(0, 12, '', { fontFamily: MONO, fontSize: '6px', color: '#e8e4d8', align: 'center' }) .setOrigin(0.5, 0); root.add([body, label]); root.setSize(40, 40).setInteractive(new Phaser.Geom.Rectangle(-20, -20, 40, 40), Phaser.Geom.Rectangle.Contains); s.input.setDraggable(root); this.root.add(root); return { root, body, label, pocketIndex: -1, itemId: '', homeX: 0, homeY: 0, grabOffX: 0, grabOffY: 0, live: false, gone: false, }; } // ---- runtime ---- private tick(): void { const frac = Phaser.Math.Clamp(this.remainingMs / PATDOWN_MS, 0, 1); this.timerBar.setSize(Math.max(576 * frac, 0.001), 8); this.timerBar.setFillStyle(frac > 0.5 ? GREEN : frac > 0.2 ? AMBER : PINK, 1); this.timerText.setText(`${(this.remainingMs / 1000).toFixed(1)}s`); this.timerText.setColor(frac > 0.5 ? '#9fe8a0' : frac > 0.2 ? '#d8a020' : '#d03470'); } private paintZone(index: number): void { const z = this.zones[index]; const p = this.pockets[index]; if (z === undefined || p === undefined) return; const cx = OUTLINE_X + (p.x + 6) * SCALE; const cy = OUTLINE_Y + (p.y + 6) * SCALE; const half = ZONE / 2; z.hit.setPosition(cx, cy); z.label.setPosition(cx, cy + half + 2).setText(p.label); z.g.clear(); if (!p.revealed) { z.g.lineStyle(1, 0x7b8cd0, 0.9); dashRect(z.g, cx - half, cy - half, ZONE, ZONE); z.label.setColor('#6f7aa8'); return; } const hot = p.itemId !== undefined && !p.binned; const c = hot ? AMBER : GREEN; z.g.fillStyle(c, 0.12); z.g.fillRect(cx - half, cy - half, ZONE, ZONE); z.g.lineStyle(1, c, 0.9); z.g.strokeRect(cx - half, cy - half, ZONE, ZONE); z.label.setColor(hot ? '#d8a020' : '#9fe8a0'); } private revealPocket(index: number): void { if (!this.open_) return; const p = this.pockets[index]; if (p === undefined || p.revealed) return; p.revealed = true; this.paintZone(index); if (p.itemId !== undefined) this.spawnToken(index, p.itemId); } private spawnToken(index: number, itemId: string): void { const p = this.pockets[index]; const t = this.tokens.find((x) => !x.live); if (p === undefined || t === undefined) return; const art = TOKEN_ART[itemId] ?? DEFAULT_ART; t.itemId = itemId; t.pocketIndex = index; t.live = true; t.gone = false; t.homeX = OUTLINE_X + (p.x + 6) * SCALE; t.homeY = OUTLINE_Y + (p.y + 6) * SCALE; t.body.setSize(art.w, art.h).setFillStyle(art.colour, 1); t.label.setText(NAME_BY_ID.get(itemId) ?? itemId).setPosition(0, art.h / 2 + 2); t.root.setPosition(t.homeX, t.homeY).setScale(1).setAlpha(1).setRotation(0).setVisible(true); if (t.root.input) t.root.input.enabled = true; this.root.bringToTop(t.root); } private hideToken(t: TokenView): void { this.scene.tweens.killTweensOf(t.root); t.live = false; t.gone = false; t.itemId = ''; t.pocketIndex = -1; t.root.setVisible(false).setRotation(0).setScale(1).setAlpha(1); if (t.root.input) t.root.input.enabled = false; } private tokenFor(obj: Phaser.GameObjects.GameObject): TokenView | undefined { return this.tokens.find((t) => t.live && !t.gone && t.root === obj); } private readonly onDragStart = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { if (!this.open_) return; const t = this.tokenFor(obj); if (t === undefined) return; // Overlay is scrollFactor 0, so track screen-space pointer, not world drag coords. t.grabOffX = t.root.x - pointer.x; t.grabOffY = t.root.y - pointer.y; this.root.bringToTop(t.root); }; private readonly onDrag = (pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { if (!this.open_) return; const t = this.tokenFor(obj); if (t === undefined) return; t.root.setPosition(pointer.x + t.grabOffX, pointer.y + t.grabOffY); }; private readonly onDragEnd = (_pointer: Phaser.Input.Pointer, obj: Phaser.GameObjects.GameObject): void => { if (!this.open_) return; const t = this.tokenFor(obj); if (t === undefined) return; if (this.binGeom.contains(t.root.x, t.root.y)) this.binToken(t); else this.scene.tweens.add({ targets: t.root, x: t.homeX, y: t.homeY, duration: 160, ease: 'Back.easeOut' }); }; private binToken(t: TokenView): void { const p = this.pockets[t.pocketIndex]; if (p === undefined) return; p.binned = true; const itemId = t.itemId; if (t.root.input) t.root.input.enabled = false; t.gone = true; // stops the flap and the drag, but keeps the slot so nothing reuses it mid-tween this.scene.tweens.add({ targets: t.root, x: BIN_X, y: BIN_Y, scale: 0.2, alpha: 0, rotation: 0, duration: 240, ease: 'Quad.easeIn', onComplete: () => t.root.setVisible(false), }); this.lineText.setText(foundLine(itemId)); this.found.push(NAME_BY_ID.get(itemId) ?? itemId); this.logText.setText(this.found.join('\n')); this.paintZone(t.pocketIndex); } private resolve(): void { const done = this.doneFn; const patron = this.patron; if (!this.open_ || done === null || patron === null) return; this.open_ = false; this.doneFn = null; this.root.setVisible(false); done(scorePatDown(patron, this.pockets)); } }