From 9d2d061bce1b39bcdcafa40857e882b662026930 Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Sun, 26 Apr 2026 23:55:55 +0800 Subject: [PATCH] fix(chase): guard seed input lifecycle and add setup reset path Made-with: Cursor --- src/stages/page_chase.ts | 59 +++++++++++++++++++++++++--- tests/stages/page_chase.test.ts | 86 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src/stages/page_chase.ts b/src/stages/page_chase.ts index bb90513..6a4bf5d 100644 --- a/src/stages/page_chase.ts +++ b/src/stages/page_chase.ts @@ -2,15 +2,23 @@ import { BaseScene } from "@/scene/BaseScene"; import { SceneType } from "@/enums/SceneType"; import { ChaseGameModel } from "@/game/chase/model"; import type { Difficulty, NodeId } from "@/game/chase/types"; -import { Container, Graphics, Text, TextStyle } from "pixi.js"; +import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; const NODE_RADIUS = 18; const NODE_GAP = 58; type ChaseModelLike = Pick; +type DocumentLike = { + body: { + appendChild: (node: HTMLInputElement) => void; + contains: (node: HTMLInputElement) => boolean; + }; + createElement: (tag: string) => HTMLInputElement; +}; type ChaseSceneOptions = { model?: ChaseModelLike; modelFactory?: () => ChaseModelLike; + documentRef?: DocumentLike; }; export default class ChaseScene extends BaseScene { @@ -28,14 +36,17 @@ export default class ChaseScene extends BaseScene { }; private startButton = new Graphics(); private seedInputElement?: HTMLInputElement; + private readonly documentRef?: DocumentLike; private selectedDifficulty: Difficulty = "normal"; private seedInput = ""; private difficultyLocked = false; + private inputAttached = false; constructor(options: ChaseSceneOptions = {}) { super("chase", SceneType.Normal); this.model = options.model; this.modelFactory = options.modelFactory; + this.documentRef = options.documentRef; } protected async onSceneLayout(): Promise { @@ -85,13 +96,17 @@ export default class ChaseScene extends BaseScene { } protected onSceneEnter(): void { - this.attachSeedInputBridge(); + this.syncSeedInputBridge(); } protected onSceneExit(): void { this.detachSeedInputBridge(); } + update(_dt: number, _name: string, _ticker: Ticker): void { + this.syncSeedInputBridge(); + } + public refreshView(): void { if (!this.model || !this.hudText || !this.setupText) { return; @@ -173,6 +188,15 @@ export default class ChaseScene extends BaseScene { this.refreshView(); } + public resetSetup(): void { + this.difficultyLocked = false; + this.seedInput = ""; + if (this.seedInputElement) { + this.seedInputElement.value = ""; + } + this.refreshView(); + } + private buildSetupControls(): void { this.setupLayer.removeChildren(); const difficulties: Difficulty[] = ["easy", "normal", "hard"]; @@ -240,10 +264,11 @@ export default class ChaseScene extends BaseScene { } private attachSeedInputBridge(): void { - if (typeof document === "undefined" || this.seedInputElement) { + const doc = this.resolveDocument(); + if (!doc || this.seedInputElement || this.inputAttached) { return; } - const input = document.createElement("input"); + const input = doc.createElement("input"); input.type = "text"; input.placeholder = "Seed (optional)"; input.value = this.seedInput; @@ -253,17 +278,31 @@ export default class ChaseScene extends BaseScene { input.style.zIndex = "10"; input.style.width = "180px"; input.addEventListener("input", this.handleSeedInputEvent); - document.body.appendChild(input); + doc.body.appendChild(input); this.seedInputElement = input; + this.inputAttached = true; } private detachSeedInputBridge(): void { if (!this.seedInputElement) { + this.inputAttached = false; return; } this.seedInputElement.removeEventListener("input", this.handleSeedInputEvent); this.seedInputElement.remove(); this.seedInputElement = undefined; + this.inputAttached = false; + } + + private syncSeedInputBridge(): void { + if (!this.resolveDocument()) { + return; + } + if (this.stage.visible) { + this.attachSeedInputBridge(); + } else { + this.detachSeedInputBridge(); + } } private readonly handleSeedInputEvent = (event: Event): void => { @@ -271,4 +310,14 @@ export default class ChaseScene extends BaseScene { this.seedInput = target?.value ?? ""; this.refreshView(); }; + + private resolveDocument(): DocumentLike | undefined { + if (this.documentRef) { + return this.documentRef; + } + if (typeof document !== "undefined") { + return document; + } + return undefined; + } } diff --git a/tests/stages/page_chase.test.ts b/tests/stages/page_chase.test.ts index 6aef980..ce1892a 100644 --- a/tests/stages/page_chase.test.ts +++ b/tests/stages/page_chase.test.ts @@ -22,6 +22,38 @@ function createSceneState() { }; } +function createFakeDocument() { + const bodyNodes = new Set(); + return { + body: { + appendChild: (node: HTMLInputElement) => { + bodyNodes.add(node); + }, + contains: (node: HTMLInputElement) => bodyNodes.has(node), + }, + createElement: (_tag: string) => { + const listeners: Record void>> = {}; + const node = { + type: "", + placeholder: "", + value: "", + style: {} as Record, + addEventListener: (name: string, handler: (event: Event) => void) => { + listeners[name] = listeners[name] ?? []; + listeners[name].push(handler); + }, + removeEventListener: (name: string, handler: (event: Event) => void) => { + listeners[name] = (listeners[name] ?? []).filter((fn) => fn !== handler); + }, + remove: () => { + bodyNodes.delete(node as unknown as HTMLInputElement); + }, + }; + return node as unknown as HTMLInputElement; + }, + }; +} + describe("chase stage skeleton", () => { it("can instantiate chase scene with name=chase", () => { const scene = new ChaseScene(); @@ -152,6 +184,28 @@ describe("chase stage skeleton", () => { expect(newRound.mock.calls[1][1]).toBe("easy"); }); + it("allows difficulty change again after resetSetup", () => { + const newRound = vi.fn(); + const getState = vi.fn().mockReturnValue(createSceneState()); + const scene = new ChaseScene({ + model: { getState, moveThief: vi.fn(), newRound } as any, + }); + void (scene as any).onSceneLayout(); + + (scene as any).difficultyButtons.easy.emit("pointerdown"); + (scene as any).startButton.emit("pointerdown"); + (scene as any).difficultyButtons.hard.emit("pointerdown"); + (scene as any).startButton.emit("pointerdown"); + + (scene as any).resetSetup(); + (scene as any).difficultyButtons.hard.emit("pointerdown"); + (scene as any).startButton.emit("pointerdown"); + + expect(newRound.mock.calls[0][1]).toBe("easy"); + expect(newRound.mock.calls[1][1]).toBe("easy"); + expect(newRound.mock.calls[2][1]).toBe("hard"); + }); + it("uses normal as default difficulty", () => { const newRound = vi.fn(); const getState = vi.fn().mockReturnValue(createSceneState()); @@ -164,4 +218,36 @@ describe("chase stage skeleton", () => { (scene as any).startButton.emit("pointerdown"); expect(newRound).toHaveBeenCalledWith(1001, "normal"); }); + + it("mounts seed input on enter and removes on exit", () => { + const fakeDocument = createFakeDocument(); + const getState = vi.fn().mockReturnValue(createSceneState()); + const scene = new ChaseScene({ + documentRef: fakeDocument as any, + model: { getState, moveThief: vi.fn(), newRound: vi.fn() } as any, + }); + void (scene as any).onSceneLayout(); + (scene as any).onSceneEnter(); + expect((scene as any).seedInputElement).toBeTruthy(); + expect(fakeDocument.body.contains((scene as any).seedInputElement)).toBe(true); + + (scene as any).onSceneExit(); + expect((scene as any).seedInputElement).toBeUndefined(); + }); + + it("detaches seed input when scene becomes invisible", () => { + const fakeDocument = createFakeDocument(); + const getState = vi.fn().mockReturnValue(createSceneState()); + const scene = new ChaseScene({ + documentRef: fakeDocument as any, + model: { getState, moveThief: vi.fn(), newRound: vi.fn() } as any, + }); + void (scene as any).onSceneLayout(); + (scene as any).onSceneEnter(); + expect((scene as any).seedInputElement).toBeTruthy(); + + scene.stage.visible = false; + scene.update(0, "chase", {} as any); + expect((scene as any).seedInputElement).toBeUndefined(); + }); });