473 lines
15 KiB
TypeScript
473 lines
15 KiB
TypeScript
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<Record<string, TokenArt>> = {
|
|
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));
|
|
}
|
|
}
|