init: board game phaser start

This commit is contained in:
2026-04-03 15:18:47 +08:00
commit 588d28ff07
23 changed files with 3552 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import Phaser from 'phaser';
import { effect, type Signal } from '@preact/signals-core';
import type { MutableSignal, Region, Part } from 'boardgame-core';
type DisposeFn = () => void;
export function bindSignal<T, K extends keyof T>(
signal: MutableSignal<T>,
getter: (state: T) => T[K],
setter: (value: T[K]) => void,
): DisposeFn {
return effect(() => {
const val = getter(signal.value);
setter(val);
});
}
export function bindGameObjectProperty<T>(
signal: Signal<T>,
target: Phaser.GameObjects.GameObject,
prop: string,
): DisposeFn {
return effect(() => {
(target as unknown as Record<string, unknown>)[prop] = signal.value;
});
}
export interface BindRegionOptions<TPart extends Part> {
cellSize: { x: number; y: number };
offset?: { x: number; y: number };
factory: (part: TPart, position: Phaser.Math.Vector2) => Phaser.GameObjects.GameObject;
}
export function bindRegion<TPart extends Part>(
region: Region,
parts: Record<string, TPart>,
options: BindRegionOptions<TPart>,
container: Phaser.GameObjects.Container,
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
const objects = new Map<string, Phaser.GameObjects.GameObject>();
const effects: DisposeFn[] = [];
const offset = options.offset ?? { x: 0, y: 0 };
function syncParts() {
const currentIds = new Set(region.childIds);
for (const [id, obj] of objects) {
if (!currentIds.has(id)) {
obj.destroy();
objects.delete(id);
}
}
for (const childId of region.childIds) {
const part = parts[childId];
if (!part) continue;
const pos = new Phaser.Math.Vector2(
part.position[0] * options.cellSize.x + offset.x,
part.position[1] * options.cellSize.y + offset.y,
);
let obj = objects.get(childId);
if (!obj) {
obj = options.factory(part, pos);
objects.set(childId, obj);
container.add(obj);
} else {
if ('setPosition' in obj && typeof obj.setPosition === 'function') {
(obj as any).setPosition(pos.x, pos.y);
}
}
}
}
const e = effect(syncParts);
effects.push(e);
return {
cleanup: () => {
for (const e of effects) e();
for (const [, obj] of objects) obj.destroy();
objects.clear();
},
objects,
};
}
export interface BindCollectionOptions<T extends { id: string }> {
factory: (item: T) => Phaser.GameObjects.GameObject;
update?: (item: T, obj: Phaser.GameObjects.GameObject) => void;
}
export function bindCollection<T extends { id: string }>(
collection: Signal<Record<string, MutableSignal<T>>>,
options: BindCollectionOptions<T>,
container: Phaser.GameObjects.Container,
): { cleanup: () => void; objects: Map<string, Phaser.GameObjects.GameObject> } {
const objects = new Map<string, Phaser.GameObjects.GameObject>();
const effects: DisposeFn[] = [];
function syncCollection() {
const entries = Object.entries(collection.value);
const currentIds = new Set(entries.map(([id]) => id));
for (const [id, obj] of objects) {
if (!currentIds.has(id)) {
obj.destroy();
objects.delete(id);
}
}
for (const [id, signal] of entries) {
let obj = objects.get(id);
if (!obj) {
obj = options.factory(signal.value);
objects.set(id, obj);
container.add(obj);
} else if (options.update) {
options.update(signal.value, obj);
}
}
}
const e = effect(syncCollection);
effects.push(e);
return {
cleanup: () => {
for (const e of effects) e();
for (const [, obj] of objects) obj.destroy();
objects.clear();
},
objects,
};
}
+14
View File
@@ -0,0 +1,14 @@
export { ReactiveScene } from './scenes/ReactiveScene';
export type { ReactiveSceneOptions } from './scenes/ReactiveScene';
export { bindSignal, bindGameObjectProperty, bindRegion, bindCollection } from './bindings';
export type { BindRegionOptions, BindCollectionOptions } from './bindings';
export { InputMapper, PromptHandler, createInputMapper, createPromptHandler } from './input';
export type { InputMapperOptions, PromptHandlerOptions } from './input';
export { GameUI } from './ui/GameUI';
export type { GameUIOptions } from './ui/GameUI';
export { PromptDialog } from './ui/PromptDialog';
export { CommandLog } from './ui/CommandLog';
+144
View File
@@ -0,0 +1,144 @@
import Phaser from 'phaser';
import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface InputMapperOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene;
commands: IGameContext<TState>['commands'];
}
export class InputMapper<TState extends Record<string, unknown>> {
private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands'];
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
constructor(options: InputMapperOptions<TState>) {
this.scene = options.scene;
this.commands = options.commands;
}
mapGridClick(
cellSize: { x: number; y: number },
offset: { x: number; y: number },
gridDimensions: { cols: number; rows: number },
onCellClick: (col: number, row: number) => string | null,
): void {
const pointerDown = (pointer: Phaser.Input.Pointer) => {
const localX = pointer.x - offset.x;
const localY = pointer.y - offset.y;
if (localX < 0 || localY < 0) return;
const col = Math.floor(localX / cellSize.x);
const row = Math.floor(localY / cellSize.y);
if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return;
const cmd = onCellClick(col, row);
if (cmd) {
this.commands.run(cmd);
}
};
this.pointerDownCallback = pointerDown;
this.scene.input.on('pointerdown', pointerDown);
}
mapObjectClick<T>(
gameObjects: Phaser.GameObjects.GameObject[],
onClick: (obj: T) => string | null,
): void {
for (const obj of gameObjects) {
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
const interactiveObj = obj as any;
interactiveObj.setInteractive({ useHandCursor: true });
interactiveObj.on('pointerdown', () => {
const cmd = onClick(obj as unknown as T);
if (cmd) {
this.commands.run(cmd);
}
});
}
}
}
destroy(): void {
if (this.pointerDownCallback) {
this.scene.input.off('pointerdown', this.pointerDownCallback);
this.pointerDownCallback = null;
}
}
}
export interface PromptHandlerOptions<TState extends Record<string, unknown>> {
scene: Phaser.Scene;
commands: IGameContext<TState>['commands'];
onPrompt: (prompt: PromptEvent) => void;
onSubmit: (input: string) => string | null;
onCancel: (reason?: string) => void;
}
export class PromptHandler<TState extends Record<string, unknown>> {
private scene: Phaser.Scene;
private commands: IGameContext<TState>['commands'];
private onPrompt: (prompt: PromptEvent) => void;
private onSubmit: (input: string) => string | null;
private onCancel: (reason?: string) => void;
private listener: ((event: PromptEvent) => void) | null = null;
constructor(options: PromptHandlerOptions<TState>) {
this.scene = options.scene;
this.commands = options.commands;
this.onPrompt = options.onPrompt;
this.onSubmit = options.onSubmit;
this.onCancel = options.onCancel;
}
start(): void {
const listener = (event: PromptEvent) => {
this.onPrompt(event);
};
this.listener = listener;
this.commands.on('prompt', listener);
this.commands.promptQueue.pop().then((promptEvent) => {
this.onPrompt(promptEvent);
}).catch(() => {
// prompt was cancelled
});
}
submit(input: string): string | null {
return this.onSubmit(input);
}
cancel(reason?: string): void {
this.onCancel(reason);
}
destroy(): void {
if (this.listener) {
this.commands.off('prompt', this.listener);
this.listener = null;
}
}
}
export function createInputMapper<TState extends Record<string, unknown>>(
scene: Phaser.Scene,
commands: IGameContext<TState>['commands'],
): InputMapper<TState> {
return new InputMapper({ scene, commands });
}
export function createPromptHandler<TState extends Record<string, unknown>>(
scene: Phaser.Scene,
commands: IGameContext<TState>['commands'],
callbacks: {
onPrompt: (prompt: PromptEvent) => void;
onSubmit: (input: string) => string | null;
onCancel: (reason?: string) => void;
},
): PromptHandler<TState> {
return new PromptHandler({ scene, commands, ...callbacks });
}
@@ -0,0 +1,47 @@
import Phaser from 'phaser';
import { effect } from '@preact/signals-core';
import type { MutableSignal, IGameContext, CommandResult } from 'boardgame-core';
type DisposeFn = () => void;
export interface ReactiveSceneOptions<TState extends Record<string, unknown>> {
state: MutableSignal<TState>;
commands: IGameContext<TState>['commands'];
}
export abstract class ReactiveScene<TState extends Record<string, unknown>> extends Phaser.Scene {
protected state!: MutableSignal<TState>;
protected commands!: IGameContext<TState>['commands'];
private effects: DisposeFn[] = [];
constructor(key: string) {
super(key);
}
protected watch(fn: () => void): DisposeFn {
const e = effect(fn);
this.effects.push(e);
return e;
}
protected async runCommand<T = unknown>(input: string): Promise<CommandResult<T>> {
return this.commands.run<T>(input);
}
create(): void {
this.events.on('shutdown', this.cleanupEffects, this);
this.onStateReady(this.state.value);
this.setupBindings();
}
private cleanupEffects(): void {
for (const e of this.effects) {
e();
}
this.effects = [];
}
protected abstract onStateReady(state: TState): void;
protected abstract setupBindings(): void;
}
+33
View File
@@ -0,0 +1,33 @@
import { h } from 'preact';
import { Signal } from '@preact/signals-core';
interface CommandLogProps {
entries: Signal<Array<{ input: string; result: string; timestamp: number }>>;
maxEntries?: number;
}
export function CommandLog({ entries, maxEntries = 50 }: CommandLogProps) {
const displayEntries = entries.value.slice(-maxEntries).reverse();
return (
<div className="bg-gray-900 text-green-400 font-mono text-xs p-3 rounded-lg overflow-y-auto max-h-48">
{displayEntries.length === 0 ? (
<div className="text-gray-500 italic">No commands yet</div>
) : (
<div className="space-y-1">
{displayEntries.map((entry, i) => (
<div key={entry.timestamp + '-' + i} className="flex gap-2">
<span className="text-gray-500">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="text-yellow-300">&gt; {entry.input}</span>
<span className={entry.result.startsWith('OK') ? 'text-green-400' : 'text-red-400'}>
{entry.result}
</span>
</div>
))}
</div>
)}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { h, Fragment } from 'preact';
import { effect } from '@preact/signals-core';
type DisposeFn = () => void;
export interface GameUIOptions {
container: HTMLElement;
root: any;
}
export class GameUI {
private container: HTMLElement;
private root: any;
private effects: DisposeFn[] = [];
constructor(options: GameUIOptions) {
this.container = options.container;
this.root = options.root;
}
mount(): void {
import('preact').then(({ render }) => {
render(this.root, this.container);
});
}
watch(fn: () => void): DisposeFn {
const e = effect(fn);
this.effects.push(e);
return e;
}
unmount(): void {
import('preact').then(({ render }) => {
render(null, this.container);
});
for (const e of this.effects) {
e();
}
this.effects = [];
}
}
+121
View File
@@ -0,0 +1,121 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-core';
interface PromptDialogProps {
prompt: PromptEvent | null;
onSubmit: (input: string) => void;
onCancel: () => void;
}
function schemaToPlaceholder(schema: CommandSchema): string {
const parts: string[] = [schema.name];
for (const param of schema.params) {
if (param.required) {
parts.push(`<${param.name}>`);
} else {
parts.push(`[${param.name}]`);
}
}
return parts.join(' ');
}
function schemaToFields(schema: CommandSchema): Array<{ param: CommandParamSchema; label: string }> {
return schema.params
.filter(p => p.required)
.map(p => ({ param: p, label: p.name }));
}
export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps) {
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(() => {
if (!prompt) return;
const fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || '');
const cmdString = [prompt.schema.name, ...fieldValues].join(' ');
const err = prompt.tryCommit(cmdString);
if (err) {
setError(err);
} else {
onSubmit(cmdString);
setValues({});
setError(null);
}
}, [prompt, values, onSubmit]);
const handleCancel = useCallback(() => {
onCancel();
setValues({});
setError(null);
}, [onCancel]);
if (!prompt) return null;
const fields = schemaToFields(prompt.schema);
const placeholder = schemaToPlaceholder(prompt.schema);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 min-w-[320px] max-w-md">
<h3 className="text-lg font-semibold mb-4 text-gray-800">{prompt.schema.name}</h3>
<p className="text-sm text-gray-500 mb-4 font-mono">{placeholder}</p>
{fields.length > 0 ? (
<div className="space-y-3 mb-4">
{fields.map(({ param, label }) => (
<div key={label}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={values[label] || ''}
onInput={(e) => setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus={fields.indexOf({ param, label }) === 0}
/>
</div>
))}
</div>
) : (
<div className="mb-4">
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter command..."
onInput={(e) => {
const val = (e.target as HTMLInputElement).value;
setValues({ _raw: val });
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus
/>
</div>
)}
{error && (
<p className="text-sm text-red-600 mb-3">{error}</p>
)}
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
onClick={handleCancel}
>
Cancel
</button>
<button
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
</div>
);
}