init: board game phaser start
This commit is contained in:
commit
588d28ff07
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "boardgame-phaser",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm --filter sample-game dev",
|
||||||
|
"build": "pnpm --filter boardgame-phaser build && pnpm --filter sample-game build",
|
||||||
|
"build:framework": "pnpm --filter boardgame-phaser build",
|
||||||
|
"preview": "pnpm --filter sample-game preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["esbuild"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "boardgame-phaser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Phaser 3 framework for board games built on boardgame-core",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"boardgame-core": ">=1.0.0",
|
||||||
|
"mutative": "^1.3.0",
|
||||||
|
"phaser": "^3.80.0",
|
||||||
|
"preact": "^10.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"boardgame-core": "file:../../../boardgame-core",
|
||||||
|
"mutative": "^1.3.0",
|
||||||
|
"phaser": "^3.80.1",
|
||||||
|
"preact": "^10.19.3",
|
||||||
|
"tsup": "^8.0.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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">> {entry.input}</span>
|
||||||
|
<span className={entry.result.startsWith('OK') ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{entry.result}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"outDir": "dist",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: {
|
||||||
|
resolve: ['boardgame-core'],
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
outDir: 'dist',
|
||||||
|
external: ['phaser', 'preact', '@preact/signals-core', 'mutative', 'boardgame-core'],
|
||||||
|
noExternal: [],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tic-Tac-Toe - boardgame-phaser</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="ui-root"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "sample-game",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"boardgame-core": "file:../../../boardgame-core",
|
||||||
|
"boardgame-phaser": "workspace:*",
|
||||||
|
"mutative": "^1.3.0",
|
||||||
|
"phaser": "^3.80.1",
|
||||||
|
"preact": "^10.19.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.8.1",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
import {
|
||||||
|
createGameCommandRegistry,
|
||||||
|
type Part,
|
||||||
|
createRegion,
|
||||||
|
type MutableSignal,
|
||||||
|
} from 'boardgame-core';
|
||||||
|
|
||||||
|
const BOARD_SIZE = 3;
|
||||||
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
|
const WINNING_LINES: number[][][] = [
|
||||||
|
[[0, 0], [0, 1], [0, 2]],
|
||||||
|
[[1, 0], [1, 1], [1, 2]],
|
||||||
|
[[2, 0], [2, 1], [2, 2]],
|
||||||
|
[[0, 0], [1, 0], [2, 0]],
|
||||||
|
[[0, 1], [1, 1], [2, 1]],
|
||||||
|
[[0, 2], [1, 2], [2, 2]],
|
||||||
|
[[0, 0], [1, 1], [2, 2]],
|
||||||
|
[[0, 2], [1, 1], [2, 0]],
|
||||||
|
];
|
||||||
|
|
||||||
|
export type PlayerType = 'X' | 'O';
|
||||||
|
export type WinnerType = PlayerType | 'draw' | null;
|
||||||
|
|
||||||
|
export type TicTacToePart = Part & { player: PlayerType };
|
||||||
|
|
||||||
|
export function createInitialState() {
|
||||||
|
return {
|
||||||
|
board: createRegion('board', [
|
||||||
|
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||||
|
]),
|
||||||
|
parts: {} as Record<string, TicTacToePart>,
|
||||||
|
currentPlayer: 'X' as PlayerType,
|
||||||
|
winner: null as WinnerType,
|
||||||
|
turn: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
|
|
||||||
|
const registration = createGameCommandRegistry<TicTacToeState>();
|
||||||
|
export const registry = registration.registry;
|
||||||
|
|
||||||
|
registration.add('setup', async function () {
|
||||||
|
const { context } = this;
|
||||||
|
while (true) {
|
||||||
|
const currentPlayer = context.value.currentPlayer;
|
||||||
|
const turnNumber = context.value.turn + 1;
|
||||||
|
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
|
||||||
|
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||||
|
|
||||||
|
context.produce(state => {
|
||||||
|
state.winner = turnOutput.result.winner;
|
||||||
|
if (!state.winner) {
|
||||||
|
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||||
|
state.turn = turnNumber;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (context.value.winner) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
registration.add('turn <player> <turn:number>', async function (cmd) {
|
||||||
|
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
||||||
|
|
||||||
|
const playCmd = await this.prompt(
|
||||||
|
'play <player> <row:number> <col:number>',
|
||||||
|
(command) => {
|
||||||
|
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||||
|
|
||||||
|
if (player !== turnPlayer) {
|
||||||
|
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
|
}
|
||||||
|
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
||||||
|
return `Invalid position: (${row}, ${col}).`;
|
||||||
|
}
|
||||||
|
if (isCellOccupied(this.context, row, col)) {
|
||||||
|
return `Cell (${row}, ${col}) is already occupied.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
||||||
|
|
||||||
|
placePiece(this.context, row, col, turnPlayer);
|
||||||
|
|
||||||
|
const winner = checkWinner(this.context);
|
||||||
|
if (winner) return { winner };
|
||||||
|
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
||||||
|
|
||||||
|
return { winner: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
||||||
|
const board = host.value.board;
|
||||||
|
return board.partMap[`${row},${col}`] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
|
return WINNING_LINES.some(line =>
|
||||||
|
line.every(([r, c]) =>
|
||||||
|
positions.some(([pr, pc]) => pr === r && pc === c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
||||||
|
const parts = Object.values(host.value.parts);
|
||||||
|
|
||||||
|
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
||||||
|
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||||
|
|
||||||
|
if (hasWinningLine(xPositions)) return 'X';
|
||||||
|
if (hasWinningLine(oPositions)) return 'O';
|
||||||
|
if (parts.length >= MAX_TURNS) return 'draw';
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||||
|
const board = host.value.board;
|
||||||
|
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||||
|
const piece: TicTacToePart = {
|
||||||
|
id: `piece-${player}-${moveNumber}`,
|
||||||
|
regionId: 'board',
|
||||||
|
position: [row, col],
|
||||||
|
player,
|
||||||
|
};
|
||||||
|
host.produce(state => {
|
||||||
|
state.parts[piece.id] = piece;
|
||||||
|
board.childIds.push(piece.id);
|
||||||
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { h, render } from 'preact';
|
||||||
|
import { signal } from '@preact/signals-core';
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import { createGameContext } from 'boardgame-core';
|
||||||
|
import { GameUI, PromptDialog, CommandLog } from 'boardgame-phaser';
|
||||||
|
import { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe';
|
||||||
|
import { GameScene, type GameSceneData } from './scenes/GameScene';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const gameContext = createGameContext<TicTacToeState>(registry, createInitialState);
|
||||||
|
|
||||||
|
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null);
|
||||||
|
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
|
||||||
|
|
||||||
|
gameContext.commands.on('prompt', (event) => {
|
||||||
|
promptSignal.value = event;
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalRun = gameContext.commands.run.bind(gameContext.commands);
|
||||||
|
(gameContext.commands as any).run = async (input: string) => {
|
||||||
|
const result = await originalRun(input);
|
||||||
|
commandLog.value = [
|
||||||
|
...commandLog.value,
|
||||||
|
{
|
||||||
|
input,
|
||||||
|
result: result.success ? `OK: ${JSON.stringify(result.result)}` : `ERR: ${result.error}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sceneData: GameSceneData = {
|
||||||
|
state: gameContext.state,
|
||||||
|
commands: gameContext.commands,
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaserConfig: Phaser.Types.Core.GameConfig = {
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: 560,
|
||||||
|
height: 560,
|
||||||
|
parent: 'phaser-container',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
scene: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const game = new Phaser.Game(phaserConfig);
|
||||||
|
|
||||||
|
game.scene.add('GameScene', GameScene, true, sceneData);
|
||||||
|
|
||||||
|
const ui = new GameUI({
|
||||||
|
container: document.getElementById('ui-root')!,
|
||||||
|
root: h('div', { className: 'flex flex-col h-screen' },
|
||||||
|
h('div', { className: 'flex-1 relative' },
|
||||||
|
h('div', { id: 'phaser-container', className: 'w-full h-full' }),
|
||||||
|
h(PromptDialog, {
|
||||||
|
prompt: promptSignal.value,
|
||||||
|
onSubmit: (input: string) => {
|
||||||
|
gameContext.commands._tryCommit(input);
|
||||||
|
promptSignal.value = null;
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
gameContext.commands._cancel('cancelled');
|
||||||
|
promptSignal.value = null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
h('div', { className: 'p-4 bg-gray-100 border-t' },
|
||||||
|
h(CommandLog, { entries: commandLog }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.mount();
|
||||||
|
|
||||||
|
gameContext.commands.run('setup');
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
import Phaser from 'phaser';
|
||||||
|
import type { TicTacToeState, TicTacToePart } from '@/game/tic-tac-toe';
|
||||||
|
import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser';
|
||||||
|
import type { PromptEvent, MutableSignal, IGameContext } from 'boardgame-core';
|
||||||
|
|
||||||
|
const CELL_SIZE = 120;
|
||||||
|
const BOARD_OFFSET = { x: 100, y: 100 };
|
||||||
|
const BOARD_SIZE = 3;
|
||||||
|
|
||||||
|
export interface GameSceneData {
|
||||||
|
state: MutableSignal<TicTacToeState>;
|
||||||
|
commands: IGameContext<TicTacToeState>['commands'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||||
|
private boardContainer!: Phaser.GameObjects.Container;
|
||||||
|
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||||
|
private inputMapper!: ReturnType<typeof createInputMapper<TicTacToeState>>;
|
||||||
|
private promptHandler!: ReturnType<typeof createPromptHandler<TicTacToeState>>;
|
||||||
|
private activePrompt: PromptEvent | null = null;
|
||||||
|
private turnText!: Phaser.GameObjects.Text;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('GameScene');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data: GameSceneData): void {
|
||||||
|
this.state = data.state;
|
||||||
|
this.commands = data.commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onStateReady(_state: TicTacToeState): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.boardContainer = this.add.container(0, 0);
|
||||||
|
this.gridGraphics = this.add.graphics();
|
||||||
|
this.drawGrid();
|
||||||
|
|
||||||
|
this.watch(() => {
|
||||||
|
const winner = this.state.value.winner;
|
||||||
|
if (winner) {
|
||||||
|
this.showWinner(winner);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.watch(() => {
|
||||||
|
const currentPlayer = this.state.value.currentPlayer;
|
||||||
|
this.updateTurnText(currentPlayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupBindings();
|
||||||
|
this.setupInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setupBindings(): void {
|
||||||
|
bindRegion<TicTacToePart>(
|
||||||
|
this.state.value.board,
|
||||||
|
this.state.value.parts,
|
||||||
|
{
|
||||||
|
cellSize: { x: CELL_SIZE, y: CELL_SIZE },
|
||||||
|
offset: BOARD_OFFSET,
|
||||||
|
factory: (part: TicTacToePart, pos: Phaser.Math.Vector2) => {
|
||||||
|
const text = this.add.text(pos.x + CELL_SIZE / 2, pos.y + CELL_SIZE / 2, part.player, {
|
||||||
|
fontSize: '64px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
return text;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.boardContainer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInput(): void {
|
||||||
|
this.inputMapper = createInputMapper(this, this.commands);
|
||||||
|
|
||||||
|
this.inputMapper.mapGridClick(
|
||||||
|
{ x: CELL_SIZE, y: CELL_SIZE },
|
||||||
|
BOARD_OFFSET,
|
||||||
|
{ cols: BOARD_SIZE, rows: BOARD_SIZE },
|
||||||
|
(col, row) => {
|
||||||
|
if (this.state.value.winner) return null;
|
||||||
|
|
||||||
|
const currentPlayer = this.state.value.currentPlayer;
|
||||||
|
const board = this.state.value.board;
|
||||||
|
if (board.partMap[`${row},${col}`]) return null;
|
||||||
|
|
||||||
|
return `play ${currentPlayer} ${row} ${col}`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.promptHandler = createPromptHandler(this, this.commands, {
|
||||||
|
onPrompt: (prompt) => {
|
||||||
|
this.activePrompt = prompt;
|
||||||
|
},
|
||||||
|
onSubmit: (input) => {
|
||||||
|
if (this.activePrompt) {
|
||||||
|
return this.activePrompt.tryCommit(input);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this.activePrompt = null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.promptHandler.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drawGrid(): void {
|
||||||
|
const g = this.gridGraphics;
|
||||||
|
g.lineStyle(3, 0x6b7280);
|
||||||
|
|
||||||
|
for (let i = 1; i < BOARD_SIZE; i++) {
|
||||||
|
g.lineBetween(
|
||||||
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||||
|
BOARD_OFFSET.y,
|
||||||
|
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||||
|
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||||
|
);
|
||||||
|
g.lineBetween(
|
||||||
|
BOARD_OFFSET.x,
|
||||||
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||||
|
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||||
|
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
g.strokePath();
|
||||||
|
|
||||||
|
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Tic-Tac-Toe', {
|
||||||
|
fontSize: '28px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#1f2937',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.turnText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 20, '', {
|
||||||
|
fontSize: '20px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#4b5563',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.updateTurnText(this.state.value.currentPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTurnText(player: string): void {
|
||||||
|
if (this.turnText) {
|
||||||
|
this.turnText.setText(`${player}'s turn`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showWinner(winner: string): void {
|
||||||
|
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
||||||
|
|
||||||
|
this.add.rectangle(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_SIZE * CELL_SIZE,
|
||||||
|
BOARD_SIZE * CELL_SIZE,
|
||||||
|
0x000000,
|
||||||
|
0.6,
|
||||||
|
);
|
||||||
|
|
||||||
|
const winText = this.add.text(
|
||||||
|
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||||
|
text,
|
||||||
|
{
|
||||||
|
fontSize: '36px',
|
||||||
|
fontFamily: 'Arial',
|
||||||
|
color: '#fbbf24',
|
||||||
|
},
|
||||||
|
).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: winText,
|
||||||
|
scale: 1.2,
|
||||||
|
duration: 500,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ui-root {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ui-root > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#phaser-container {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#phaser-container canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue