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
+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>
);
}