@khorsheed/dsh-game 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.en.md +53 -0
- package/README.i18n.yaml +6 -0
- package/README.md +52 -2
- package/cordis.patch.yml +23 -0
- package/lib/client.js +30690 -0
- package/lib/index.js +4297 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3876 -0
- package/lib/typert.remote-client.d.ts +104 -0
- package/lib/typert.remote-client.js +2138 -0
- package/lib/types/agent.d.ts +20 -0
- package/lib/types/agent.js +43 -0
- package/lib/types/checks.d.ts +44 -0
- package/lib/types/checks.js +267 -0
- package/lib/types/client/DesignView.d.ts +22 -0
- package/lib/types/client/DesignView.js +134 -0
- package/lib/types/client/ElementsView.d.ts +21 -0
- package/lib/types/client/ElementsView.js +195 -0
- package/lib/types/client/GameTab.d.ts +14 -0
- package/lib/types/client/GameTab.js +261 -0
- package/lib/types/client/PlayFrame.d.ts +54 -0
- package/lib/types/client/PlayFrame.js +129 -0
- package/lib/types/client/PlayView.d.ts +19 -0
- package/lib/types/client/PlayView.js +163 -0
- package/lib/types/client/Preview3D.d.ts +52 -0
- package/lib/types/client/Preview3D.js +178 -0
- package/lib/types/client/ProjectSwitcher.d.ts +20 -0
- package/lib/types/client/ProjectSwitcher.js +75 -0
- package/lib/types/client/Recipe3DEditor.d.ts +19 -0
- package/lib/types/client/Recipe3DEditor.js +44 -0
- package/lib/types/client/StyleGallery.d.ts +18 -0
- package/lib/types/client/StyleGallery.js +27 -0
- package/lib/types/client/TurnCard.d.ts +62 -0
- package/lib/types/client/TurnCard.js +100 -0
- package/lib/types/client/VersionsView.d.ts +17 -0
- package/lib/types/client/VersionsView.js +68 -0
- package/lib/types/client/chat-node.d.ts +42 -0
- package/lib/types/client/chat-node.js +77 -0
- package/lib/types/client/contract.d.ts +131 -0
- package/lib/types/client/contract.js +6 -0
- package/lib/types/client/definition.d.ts +22 -0
- package/lib/types/client/definition.js +28 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/index.js +263 -0
- package/lib/types/client/locales.d.ts +379 -0
- package/lib/types/client/locales.js +733 -0
- package/lib/types/client/looks.d.ts +36 -0
- package/lib/types/client/looks.js +105 -0
- package/lib/types/client/markdown.d.ts +11 -0
- package/lib/types/client/markdown.js +52 -0
- package/lib/types/client/preset-visibility.d.ts +94 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/preview-entry.d.ts +24 -0
- package/lib/types/client/preview-entry.js +110 -0
- package/lib/types/client/refs.d.ts +36 -0
- package/lib/types/client/refs.js +44 -0
- package/lib/types/client/reveal.d.ts +43 -0
- package/lib/types/client/reveal.js +73 -0
- package/lib/types/client/style-samples.d.ts +5 -0
- package/lib/types/client/style-samples.js +196 -0
- package/lib/types/fsutil.d.ts +26 -0
- package/lib/types/fsutil.js +104 -0
- package/lib/types/hooks.d.ts +83 -0
- package/lib/types/hooks.js +108 -0
- package/lib/types/index.d.ts +30 -0
- package/lib/types/index.js +22 -0
- package/lib/types/kits/canvas2d.d.ts +6 -0
- package/lib/types/kits/canvas2d.js +60 -0
- package/lib/types/kits/ids.d.ts +11 -0
- package/lib/types/kits/ids.js +6 -0
- package/lib/types/kits/index.d.ts +26 -0
- package/lib/types/kits/index.js +28 -0
- package/lib/types/kits/three3d.d.ts +12 -0
- package/lib/types/kits/three3d.js +188 -0
- package/lib/types/kits/types.d.ts +52 -0
- package/lib/types/kits/types.js +6 -0
- package/lib/types/look3d.d.ts +20 -0
- package/lib/types/look3d.js +175 -0
- package/lib/types/panel-session.d.ts +21 -0
- package/lib/types/panel-session.js +16 -0
- package/lib/types/project.d.ts +138 -0
- package/lib/types/project.js +504 -0
- package/lib/types/remote.d.ts +184 -0
- package/lib/types/remote.js +263 -0
- package/lib/types/skills.d.ts +13 -0
- package/lib/types/skills.js +141 -0
- package/lib/types/store.d.ts +346 -0
- package/lib/types/store.js +1383 -0
- package/lib/types/tools.d.ts +12 -0
- package/lib/types/tools.js +309 -0
- package/lib/types/trash.d.ts +5 -0
- package/lib/types/trash.js +64 -0
- package/lib/types/types.d.ts +548 -0
- package/lib/types/types.js +85 -0
- package/lib/types/vfs.d.ts +94 -0
- package/lib/types/vfs.js +217 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +132 -3
- package/runtime/dsh3d.js +1279 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* 元素: every element of the game as a card grid (look, name, key numbers),
|
|
4
|
+
* filtered by type, multi-selectable to hand to the model as references.
|
|
5
|
+
* One card opens the element: its look large (a 3D turntable, or the pixel
|
|
6
|
+
* editor), right under it a box to ask the model for a new look and the
|
|
7
|
+
* model's A/B/C proposals, its 设定 (lore), the 3D recipe's fine-tuning
|
|
8
|
+
* folded away, and its fields. Every edit lands in the working files; the
|
|
9
|
+
* tab's commit bar turns them into a version for the play area.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
+
import { ChipRow, PanelHeader } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
13
|
+
import { isPixelLook, LOOK_CHARS, LORE_MAX } from "../types.js";
|
|
14
|
+
import { failureOf } from "./contract.js";
|
|
15
|
+
import { blankLook, ensureColor, LookCanvas, recolor, setCell } from "./looks.js";
|
|
16
|
+
import { Preview3D } from "./Preview3D.js";
|
|
17
|
+
import { Recipe3DEditor } from "./Recipe3DEditor.js";
|
|
18
|
+
import css from './GameTab.module.css';
|
|
19
|
+
/** A 3D look's main color, for its card's placeholder while the still renders (or without a live version). */
|
|
20
|
+
function bodyColor(look) {
|
|
21
|
+
const recipe = look.kind === 'parts3d' ? look : look.recipe;
|
|
22
|
+
return recipe?.palette.body ?? Object.values(recipe?.palette ?? {})[0];
|
|
23
|
+
}
|
|
24
|
+
/** A stable per-type hue index for cards without a look. */
|
|
25
|
+
function typeTone(types, type) {
|
|
26
|
+
return Math.max(0, types.indexOf(type)) % 6;
|
|
27
|
+
}
|
|
28
|
+
/** The elements view. */
|
|
29
|
+
export function ElementsView(props) {
|
|
30
|
+
const { t, project, openId, onOpen, insertDraft, toast } = props;
|
|
31
|
+
const types = useMemo(() => [...new Set([...(project.manifest?.elementTypes ?? []), ...project.elements.map(e => e.type)])], [project]);
|
|
32
|
+
const [filter, setFilter] = useState('all');
|
|
33
|
+
const [selected, setSelected] = useState([]);
|
|
34
|
+
const open = project.elements.find(e => e.id === openId);
|
|
35
|
+
if (open !== undefined)
|
|
36
|
+
return _jsx(ElementDetail, { ...props, element: open, onBack: () => { onOpen(null); }, types: types });
|
|
37
|
+
// Newest change first (stable: elements of one file keep their order).
|
|
38
|
+
const shown = project.elements.filter(e => filter === 'all' || e.type === filter)
|
|
39
|
+
.map((e, i) => ({ e, i })).sort((a, b) => (b.e.updatedAt ?? 0) - (a.e.updatedAt ?? 0) || a.i - b.i).map(x => x.e);
|
|
40
|
+
const openProposals = new Set(project.proposals.filter(p => p.picked === undefined).map(p => p.elementId));
|
|
41
|
+
const toggle = (id) => { setSelected(s => (s.includes(id) ? s.filter(x => x !== id) : [...s, id])); };
|
|
42
|
+
return _jsxs("div", { className: css.scroll, children: [project.elementErrors.length > 0 && _jsxs("div", { className: css.notice, "data-tone": "error", children: [_jsx("strong", { children: t('elements.errors', { count: project.elementErrors.length }) }), _jsx("ul", { className: css.plainList, children: project.elementErrors.slice(0, 6).map((f, i) => _jsxs("li", { children: [_jsx("code", { children: f.where }), " ", f.message] }, i)) })] }), project.elements.length === 0
|
|
43
|
+
? _jsxs("div", { className: css.emptyCard, children: [_jsx("div", { className: css.emptyGlyph, "aria-hidden": "true", children: "\u25C6" }), _jsx("p", { children: t('elements.empty') })] })
|
|
44
|
+
: _jsxs(_Fragment, { children: [_jsx(ChipRow, { label: t('elements.filter'), value: filter, onSelect: setFilter, chips: [{ id: 'all', label: t('elements.all'), count: project.elements.length },
|
|
45
|
+
...types.map(type => ({ id: type, label: type, count: project.elements.filter(e => e.type === type).length }))] }), _jsx("div", { className: css.cardGrid, children: shown.map(e => {
|
|
46
|
+
const stats = Object.entries(e.stats).filter(([, v]) => typeof v === 'number').slice(0, 2);
|
|
47
|
+
const isSel = selected.includes(e.id);
|
|
48
|
+
return _jsxs("div", { className: css.elCard, "data-selected": isSel ? 'true' : 'false', children: [_jsxs("button", { type: "button", className: css.elOpen, onClick: () => { onOpen(e.id); }, "aria-label": t('elements.open', { name: e.name }), children: [_jsx("span", { className: css.elArt, "data-tone": typeTone(types, e.type), children: e.look !== null ? _jsx(LookCanvas, { look: e.look, box: 56 })
|
|
49
|
+
: e.look3d !== null ? _jsx(Preview3D, { api: props.api, projectId: project.id, version: project.current?.version, look: e.look3d, mode: "still", className: css.elArt3d, emptyLabel: "", placeholder: _jsx("span", { className: css.elInitial, style: { background: bodyColor(e.look3d) }, "data-model": "3d", children: e.name.slice(0, 1) }) })
|
|
50
|
+
: _jsx("span", { className: css.elInitial, children: e.name.slice(0, 1) }) }), _jsx("span", { className: css.elName, children: e.name }), _jsxs("span", { className: css.elMeta, children: [e.type, stats.length > 0 ? ` · ${stats.map(([k, v]) => `${k} ${String(v)}`).join(' · ')}` : ''] }), openProposals.has(e.id) && _jsx("span", { className: css.badge, children: t('elements.proposalsWaiting') })] }), _jsx("label", { className: css.elCheck, title: t('elements.select'), children: _jsx("input", { type: "checkbox", checked: isSel, onChange: () => { toggle(e.id); }, "aria-label": t('elements.selectOne', { name: e.name }) }) })] }, e.id);
|
|
51
|
+
}) })] }), selected.length > 0 && _jsxs("div", { className: css.selectBar, children: [_jsx("span", { children: t('elements.selected', { count: selected.length }) }), _jsx("span", { className: css.spacer }), _jsx("button", { type: "button", className: css.ghost, onClick: () => { setSelected([]); }, children: t('elements.clear') }), _jsx("button", { type: "button", className: css.primary, onClick: () => {
|
|
52
|
+
const refs = selected.map(id => project.elements.find(e => e.id === id)).filter((e) => e !== undefined);
|
|
53
|
+
if (insertDraft(t('draft.elementsRef', { list: refs.map(e => `「${e.name}」(${e.id})`).join('、') }))) {
|
|
54
|
+
toast(t('toast.drafted'));
|
|
55
|
+
setSelected([]);
|
|
56
|
+
}
|
|
57
|
+
}, children: t('elements.toModel') })] })] });
|
|
58
|
+
}
|
|
59
|
+
/** One element, open. */
|
|
60
|
+
function ElementDetail({ t, sessionId, api, project, element, onBack, insertDraft, toast, refresh, types }) {
|
|
61
|
+
const stylePalette = project.manifest?.palette ?? [];
|
|
62
|
+
const [look, setLook] = useState(element.look);
|
|
63
|
+
const [state, setState] = useState(Object.keys(element.look?.states ?? { idle: [] })[0] ?? 'idle');
|
|
64
|
+
const [tool, setTool] = useState('pen');
|
|
65
|
+
const [color, setColor] = useState(stylePalette[1] ?? element.look?.palette[1] ?? null);
|
|
66
|
+
const [recolorFrom, setRecolorFrom] = useState(null);
|
|
67
|
+
const [ask, setAsk] = useState('');
|
|
68
|
+
const [saving, setSaving] = useState('idle');
|
|
69
|
+
const saveTimer = useRef();
|
|
70
|
+
useEffect(() => { setLook(element.look); }, [element.id, element.look]);
|
|
71
|
+
const [look3d, setLook3d] = useState(element.look3d);
|
|
72
|
+
const [clips, setClips] = useState([]);
|
|
73
|
+
const [parts, setParts] = useState(null);
|
|
74
|
+
const [clip, setClip] = useState(null);
|
|
75
|
+
const [view, setView] = useState(null);
|
|
76
|
+
const viewN = useRef(0);
|
|
77
|
+
const turnTo = (yaw) => { viewN.current++; setView({ yaw, n: viewN.current }); };
|
|
78
|
+
useEffect(() => { setClips([]); setClip(null); setParts(null); }, [element.id]);
|
|
79
|
+
useEffect(() => { setLook3d(element.look3d); }, [element.id, element.look3d]);
|
|
80
|
+
const save = (next) => {
|
|
81
|
+
if (isPixelLook(next))
|
|
82
|
+
setLook(next);
|
|
83
|
+
else
|
|
84
|
+
setLook3d(next);
|
|
85
|
+
setSaving('saving');
|
|
86
|
+
clearTimeout(saveTimer.current);
|
|
87
|
+
saveTimer.current = setTimeout(() => {
|
|
88
|
+
void api.editElement(sessionId, project.id, element.id, { look: JSON.stringify(next) }).then(result => {
|
|
89
|
+
if (failureOf(result) !== undefined) {
|
|
90
|
+
setSaving('failed');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setSaving('saved');
|
|
94
|
+
refresh();
|
|
95
|
+
});
|
|
96
|
+
}, 450);
|
|
97
|
+
};
|
|
98
|
+
const paint = (x, y) => {
|
|
99
|
+
if (look === null || tool !== 'pen')
|
|
100
|
+
return;
|
|
101
|
+
if (color === null) {
|
|
102
|
+
save(setCell(look, state, x, y, '.'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const ensured = ensureColor(look, color);
|
|
106
|
+
save(setCell(ensured.look, state, x, y, ensured.char));
|
|
107
|
+
};
|
|
108
|
+
const saveField = (key, raw, kind) => {
|
|
109
|
+
const value = kind === 'number' ? Number(raw) : kind === 'boolean' ? raw === 'true' : raw;
|
|
110
|
+
if (kind === 'number' && !Number.isFinite(value))
|
|
111
|
+
return;
|
|
112
|
+
if (element.stats[key] === value)
|
|
113
|
+
return;
|
|
114
|
+
void api.editElement(sessionId, project.id, element.id, { fields: JSON.stringify({ [key]: value }) }).then(refresh);
|
|
115
|
+
};
|
|
116
|
+
const proposals = project.proposals.filter(p => p.elementId === element.id);
|
|
117
|
+
const openProposal = proposals.find(p => p.picked === undefined);
|
|
118
|
+
const history = proposals.filter(p => p.picked !== undefined);
|
|
119
|
+
const states = look === null ? [] : Object.keys(look.states);
|
|
120
|
+
const fields = Object.entries(element.stats);
|
|
121
|
+
return _jsxs("div", { className: css.scroll, children: [_jsx(PanelHeader, { className: css.detailHead, title: element.name, meta: _jsx("span", { title: `${element.id} · ${element.file}`, children: element.type }), onBack: onBack, backLabel: t('elements.back'), moreLabel: t('more') }), look3d !== null ? _jsxs("section", { className: css.lookPanel, children: [_jsx(Preview3D, { api: api, projectId: project.id, version: project.current?.version, look: look3d, mode: "spin", className: css.turntable, emptyLabel: t('look.preview3dEmpty'), onClips: (names, found) => { setClips(names); setParts(found); }, onTurned: () => { setView(null); }, clip: clip, view: view }), clips.length > 0 && _jsx(ChipRow, { label: t('gltf.clips'), value: clip?.name ?? autoClip(look3d, clips), onSelect: name => { setClip(c => ({ name, n: (c?.n ?? 0) + 1 })); }, chips: clips.map(name => ({ id: name, label: name })) }), _jsxs("div", { className: `${css.toolRow} ${css.tight}`, children: [_jsx("span", { className: css.metaLine, children: t('look.drag3d') }), _jsx("span", { className: css.spacer }), _jsx("span", { role: "group", "aria-label": t('look.views'), className: css.segGroup, children: [[0, 'look.front'], [90, 'look.side'], [180, 'look.back']].map(([yaw, key]) => _jsx("button", { type: "button", className: css.seg, "aria-pressed": view?.yaw === yaw, onClick: () => { turnTo(yaw); }, children: t(key) }, yaw)) }), _jsx("span", { className: css.saveState, "data-state": saving, children: saving === 'idle' ? '' : t(`look.${saving}`) })] }), look3d.kind === 'gltf' && _jsx(GltfControls, { t: t, look: look3d, onChange: save })] }) : _jsxs("section", { className: css.lookPanel, children: [_jsxs("div", { className: css.lookMain, children: [look !== null
|
|
122
|
+
? _jsx(LookCanvas, { look: look, state: state, box: 208, grid: true, onPaint: paint, className: css.lookEditor })
|
|
123
|
+
: _jsxs("div", { className: css.lookEmpty, "data-tone": typeTone(types, element.type), children: [_jsx("p", { children: t('look.none') }), _jsx("button", { type: "button", className: css.ghost, onClick: () => { save(blankLook(stylePalette.length > 0 ? stylePalette : ['#000000', '#ffffff'])); }, children: t('look.start') })] }), look !== null && _jsxs("div", { className: css.lookSide, children: [_jsx("div", { className: css.lookPreview, children: _jsx(LookCanvas, { look: look, state: state, box: 64 }) }), _jsx("span", { className: css.metaLine, children: t('look.inGame') }), _jsx("span", { className: css.saveState, "data-state": saving, children: saving === 'idle' ? '' : t(`look.${saving}`) })] })] }), states.length > 0 && _jsx(ChipRow, { label: t('look.states'), value: state, onSelect: setState, chips: states.map(s => ({ id: s, label: s })) }), look !== null && _jsxs(_Fragment, { children: [_jsxs("div", { className: `${css.toolRow} ${css.tight}`, children: [_jsxs("span", { className: css.segGroup, role: "group", "aria-label": t('look.tools'), children: [_jsx("button", { type: "button", className: css.seg, "aria-pressed": tool === 'pen', onClick: () => { setTool('pen'); setRecolorFrom(null); }, children: t('look.pen') }), _jsx("button", { type: "button", className: css.seg, "aria-pressed": tool === 'recolor', onClick: () => { setTool('recolor'); }, children: t('look.recolor') })] }), _jsx("span", { className: css.spacer }), _jsx("span", { className: css.metaLine, children: tool === 'pen' ? t('look.penHint') : recolorFrom === null ? t('look.recolorPick') : t('look.recolorTo') })] }), tool === 'recolor' && _jsx("div", { className: css.swatches, "aria-label": t('look.ownColors'), children: look.palette.map((c, i) => _jsx("button", { type: "button", className: css.swatchBtn, "aria-pressed": recolorFrom === i, style: { background: c }, title: `${LOOK_CHARS[i]} ${c}`, onClick: () => { setRecolorFrom(i); } }, i)) }), _jsxs("div", { className: css.swatches, "aria-label": t('look.styleColors'), children: [tool === 'pen' && _jsx("button", { type: "button", className: css.swatchBtn, "data-erase": "true", "aria-pressed": color === null, title: t('look.erase'), onClick: () => { setColor(null); } }), stylePalette.map(c => _jsx("button", { type: "button", className: css.swatchBtn, style: { background: c }, title: c, "aria-pressed": tool === 'pen' && color === c, onClick: () => {
|
|
124
|
+
if (tool === 'pen')
|
|
125
|
+
setColor(c);
|
|
126
|
+
else if (recolorFrom !== null) {
|
|
127
|
+
save(recolor(look, recolorFrom, c));
|
|
128
|
+
setRecolorFrom(null);
|
|
129
|
+
}
|
|
130
|
+
} }, c))] }), _jsx("p", { className: css.metaLine, children: t('look.paletteNote') })] })] }), _jsxs("section", { className: css.block, children: [_jsx("h4", { className: css.blockTitle, children: t('look.askTitle') }), _jsx("textarea", { className: css.textarea, rows: 2, value: ask, placeholder: t('look.askPlaceholder'), onChange: e => { setAsk(e.target.value); } }), _jsx("div", { className: css.rowEnd, children: _jsx("button", { type: "button", className: css.primary, disabled: ask.trim() === '', onClick: () => {
|
|
131
|
+
if (insertDraft(t('draft.restyleElement', { name: element.name, id: element.id, ask: ask.trim() }))) {
|
|
132
|
+
toast(t('toast.drafted'));
|
|
133
|
+
setAsk('');
|
|
134
|
+
}
|
|
135
|
+
}, children: t('look.askSend') }) }), openProposal !== undefined && _jsxs("div", { className: css.proposals, children: [_jsx("p", { className: css.metaLine, children: t('look.proposalsFor', { request: openProposal.request || '—' }) }), _jsx("div", { className: css.proposalGrid, children: openProposal.variants.map((v, i) => _jsxs("div", { className: css.proposalCard, children: [_jsx("div", { className: css.proposalArt, children: isPixelLook(v.look) ? _jsx(LookCanvas, { look: v.look, box: 84 })
|
|
136
|
+
: _jsx(Preview3D, { api: api, projectId: project.id, version: project.current?.version, look: v.look, mode: "still", className: css.proposal3d, emptyLabel: t('look.model3d') }) }), _jsx("strong", { children: v.label }), _jsx("span", { className: css.metaLine, children: v.note }), _jsx("button", { type: "button", className: css.primary, onClick: () => {
|
|
137
|
+
void api.pickProposal(sessionId, project.id, element.id, openProposal.at, i).then(r => {
|
|
138
|
+
if (failureOf(r) === undefined)
|
|
139
|
+
toast(t('nudge.picked', { name: element.name, label: v.label }));
|
|
140
|
+
refresh();
|
|
141
|
+
});
|
|
142
|
+
}, children: t('look.pickThis') })] }, i)) })] }), history.length > 0 && _jsxs("details", { className: css.history, children: [_jsx("summary", { children: t('look.history', { count: history.length }) }), history.map(p => _jsx("div", { className: css.historyRow, children: p.variants.map((v, i) => _jsxs("span", { className: css.historyItem, "data-picked": i === p.picked ? 'true' : 'false', children: [isPixelLook(v.look) ? _jsx(LookCanvas, { look: v.look, box: 32 }) : _jsx("span", { className: css.metaLine, children: t('look.model3d') }), _jsx("button", { type: "button", className: css.link, onClick: () => { save(v.look); }, children: t('look.useAgain', { label: v.label }) })] }, i)) }, p.at))] })] }), _jsx(LoreCard, { t: t, element: element, onSave: lore => {
|
|
143
|
+
void api.editElement(sessionId, project.id, element.id, { fields: JSON.stringify({ lore: lore === '' ? null : lore }) }).then(refresh);
|
|
144
|
+
} }), look3d?.kind === 'parts3d' && _jsxs("details", { className: css.fold, children: [_jsx("summary", { children: t('look.fineTune') }), _jsx(Recipe3DEditor, { t: t, look: look3d, onChange: save })] }), look3d?.kind === 'gltf' && parts !== null && (parts.materials.length + parts.bones.length + parts.morphs.length) > 0 && _jsxs("details", { className: css.fold, children: [_jsx("summary", { children: t('look.fineTune') }), _jsx(GltfTweaks, { t: t, look: look3d, parts: parts, onChange: save })] }), _jsxs("section", { className: css.block, children: [_jsx("h4", { className: css.blockTitle, children: t('fields.title') }), fields.length === 0 && _jsx("p", { className: css.metaLine, children: t('fields.none') }), _jsx("div", { className: css.fieldGrid, children: fields.map(([key, value]) => _jsxs("label", { className: css.field, children: [_jsx("span", { className: css.fieldKey, children: key }), typeof value === 'boolean'
|
|
145
|
+
? _jsx("input", { type: "checkbox", defaultChecked: value, onChange: e => { saveField(key, String(e.target.checked), 'boolean'); } })
|
|
146
|
+
: _jsx("input", { className: css.input, defaultValue: String(value), inputMode: typeof value === 'number' ? 'decimal' : 'text', onBlur: e => { saveField(key, e.target.value, typeof value === 'number' ? 'number' : 'string'); }, onKeyDown: e => { if (e.key === 'Enter')
|
|
147
|
+
e.target.blur(); } })] }, `${element.id}:${key}`)) }), _jsx("p", { className: css.metaLine, children: t('fields.note') })] })] });
|
|
148
|
+
}
|
|
149
|
+
/** 设定: who the element is — free text the agent follows for its behavior and wording. Saved on blur. */
|
|
150
|
+
function LoreCard({ t, element, onSave }) {
|
|
151
|
+
const [text, setText] = useState(element.lore);
|
|
152
|
+
useEffect(() => { setText(element.lore); }, [element.id, element.lore]);
|
|
153
|
+
return _jsxs("section", { className: css.block, children: [_jsx("h4", { className: css.blockTitle, children: t('lore.title') }), _jsx("textarea", { className: css.textarea, rows: element.lore === '' ? 3 : 5, maxLength: LORE_MAX, value: text, placeholder: t('lore.placeholder'), "aria-label": t('lore.title'), onChange: e => { setText(e.target.value); }, onBlur: () => { if (text.trim() !== element.lore)
|
|
154
|
+
onSave(text.trim()); } }), _jsx("p", { className: css.metaLine, children: t('lore.note') })] });
|
|
155
|
+
}
|
|
156
|
+
/** A model-file look: which file, toon or its own materials, and its size in the game. */
|
|
157
|
+
function GltfControls({ t, look, onChange }) {
|
|
158
|
+
const material = look.material ?? 'toon';
|
|
159
|
+
const [scale, setScale] = useState(String(look.scale ?? 1));
|
|
160
|
+
useEffect(() => { setScale(String(look.scale ?? 1)); }, [look.scale]);
|
|
161
|
+
return _jsxs("div", { className: css.gltfRow, children: [_jsx("span", { className: css.metaLine, children: t('gltf.file', { src: look.src }) }), _jsx("span", { className: css.spacer }), _jsxs("span", { role: "group", "aria-label": t('gltf.material'), className: css.segGroup, children: [_jsx("button", { type: "button", className: css.seg, "aria-pressed": material === 'toon', onClick: () => { onChange({ ...look, material: 'toon' }); }, children: t('gltf.toon') }), _jsx("button", { type: "button", className: css.seg, "aria-pressed": material === 'original', onClick: () => { onChange({ ...look, material: 'original' }); }, children: t('gltf.original') })] }), _jsxs("label", { className: css.gltfScale, children: [_jsx("span", { className: css.metaLine, children: t('gltf.scale') }), _jsx("input", { className: css.input, inputMode: "decimal", value: scale, onChange: e => { setScale(e.target.value); }, onBlur: () => { const v = Number(scale); if (Number.isFinite(v) && v > 0 && v !== (look.scale ?? 1))
|
|
162
|
+
onChange({ ...look, scale: v }); }, onKeyDown: e => { if (e.key === 'Enter')
|
|
163
|
+
e.target.blur(); } })] })] });
|
|
164
|
+
}
|
|
165
|
+
/** Rig helpers a user never tunes (Rigify and friends): hidden from the bone list unless already tweaked. */
|
|
166
|
+
const RIG_HELPER = /^(DEF|MCH|ORG|CTRL|WGT|IK|FK|VIS|MSTR)[-_.]/i;
|
|
167
|
+
/**
|
|
168
|
+
* A model look tuned by the names inside it: base color per material, a size
|
|
169
|
+
* (and, folded, a rotation) per bone, a weight per shape key. Every change is
|
|
170
|
+
* a look edit (live in the turntable, saved like any other).
|
|
171
|
+
*/
|
|
172
|
+
function GltfTweaks({ t, look, parts, onChange }) {
|
|
173
|
+
const [query, setQuery] = useState('');
|
|
174
|
+
const tint = look.tint ?? {}, bones = look.bones ?? {}, morphs = look.morphs ?? {};
|
|
175
|
+
const set = (key, value) => { onChange({ ...look, [key]: value }); };
|
|
176
|
+
const without = (map, name) => { const next = { ...map }; delete next[name]; return next; };
|
|
177
|
+
const shown = parts.bones.filter(b => bones[b] !== undefined || (!RIG_HELPER.test(b) && (query === '' || b.toLowerCase().includes(query.toLowerCase()))));
|
|
178
|
+
const boneScale = (name) => { const s = bones[name]?.scale; return typeof s === 'number' ? s : 1; };
|
|
179
|
+
const rot = (name) => bones[name]?.rotate ?? [0, 0, 0];
|
|
180
|
+
const setBone = (name, patch) => {
|
|
181
|
+
const next = { ...bones[name], ...patch };
|
|
182
|
+
const plain = (next.scale === undefined || next.scale === 1) && (next.rotate === undefined || next.rotate.every(v => v === 0));
|
|
183
|
+
set('bones', plain ? without(bones, name) : { ...bones, [name]: next });
|
|
184
|
+
};
|
|
185
|
+
return _jsxs("div", { className: css.recipe, children: [parts.materials.length > 0 && _jsxs("div", { className: css.recipeGroup, children: [_jsx("span", { className: css.recipePartName, children: t('gltf.colors') }), _jsx("div", { className: css.recipeColors, children: parts.materials.map(m => _jsxs("label", { className: css.recipeColor, title: m.vertexColors ? t('gltf.vertexColorsHint') : undefined, children: [_jsx("input", { type: "color", value: tint[m.name] ?? m.color, onChange: e => { set('tint', { ...tint, [m.name]: e.target.value }); } }), _jsxs("span", { children: [m.name, m.vertexColors ? ` · ${t('gltf.vertexColors')}` : ''] }), tint[m.name] !== undefined && _jsx("button", { type: "button", className: css.link, onClick: () => { set('tint', without(tint, m.name)); }, children: t('gltf.reset') })] }, m.name)) })] }), parts.morphs.length > 0 && _jsxs("div", { className: css.recipeGroup, children: [_jsx("span", { className: css.recipePartName, children: t('gltf.morphs') }), parts.morphs.map(name => _jsxs("label", { className: css.param, children: [_jsx("span", { className: css.paramLabel, children: name }), _jsx("input", { type: "range", className: css.range, min: 0, max: 1, step: 0.05, value: morphs[name] ?? 0, onChange: e => { const v = Number(e.target.value); set('morphs', v === 0 ? without(morphs, name) : { ...morphs, [name]: v }); } }), _jsx("span", { className: css.paramValue, children: (morphs[name] ?? 0).toFixed(2) })] }, name))] }), parts.bones.length > 0 && _jsxs("div", { className: css.recipeGroup, children: [_jsx("span", { className: css.recipePartName, children: t('gltf.bones') }), _jsx("input", { className: css.input, value: query, placeholder: t('gltf.boneSearch'), "aria-label": t('gltf.boneSearch'), onChange: e => { setQuery(e.target.value); } }), shown.slice(0, 40).map(name => _jsxs("div", { className: css.recipePart, children: [_jsxs("label", { className: css.param, children: [_jsx("span", { className: css.paramLabel, children: name }), _jsx("input", { type: "range", className: css.range, min: 0.5, max: 2, step: 0.05, value: boneScale(name), onChange: e => { setBone(name, { scale: Number(e.target.value) }); } }), _jsxs("span", { className: css.paramValue, children: ["\u00D7", boneScale(name).toFixed(2)] })] }), _jsxs("details", { className: css.history, children: [_jsx("summary", { children: t('gltf.rotate') }), ['x', 'y', 'z'].map((axis, i) => _jsxs("label", { className: css.param, children: [_jsx("span", { className: css.paramLabel, children: axis }), _jsx("input", { type: "range", className: css.range, min: -45, max: 45, step: 1, value: rot(name)[i], onChange: e => {
|
|
186
|
+
const next = [...rot(name)];
|
|
187
|
+
next[i] = Number(e.target.value);
|
|
188
|
+
setBone(name, { rotate: next });
|
|
189
|
+
} }), _jsxs("span", { className: css.paramValue, children: [rot(name)[i], "\u00B0"] })] }, axis))] })] }, name)), shown.length > 40 && _jsx("span", { className: css.metaLine, children: t('gltf.moreBones', { count: shown.length - 40 }) })] })] });
|
|
190
|
+
}
|
|
191
|
+
/** The clip a model plays on its own (dsh3d's rule): the look's choice, else an idle-like name, else the first. */
|
|
192
|
+
function autoClip(look, clips) {
|
|
193
|
+
const chosen = look.kind === 'gltf' ? look.animation : undefined;
|
|
194
|
+
return chosen !== undefined && clips.includes(chosen) ? chosen : clips.find(c => /idle|待机|stand/i.test(c)) ?? clips[0] ?? '';
|
|
195
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The game tab body — the game-design workbench beside the conversation.
|
|
3
|
+
* With a project bound: a header (title, style, live version, the
|
|
4
|
+
* auto-confirm switch), the commit bar while panel edits wait to become a
|
|
5
|
+
* version, and four views — 试玩 (stage + parameter drawer),
|
|
6
|
+
* 元素 (cards, look editor, proposals), 策划 (one-pager, pillars, chapters)
|
|
7
|
+
* and 版本 (checks, timeline, log, export). Without one: how to start, the
|
|
8
|
+
* style gallery, and the existing projects.
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from 'react';
|
|
11
|
+
import { type GameTabProps } from './contract.ts';
|
|
12
|
+
/** The game tab body. */
|
|
13
|
+
export declare function GameTab(props: GameTabProps): ReactNode;
|
|
14
|
+
//# sourceMappingURL=GameTab.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The game tab body — the game-design workbench beside the conversation.
|
|
4
|
+
* With a project bound: a header (title, style, live version, the
|
|
5
|
+
* auto-confirm switch), the commit bar while panel edits wait to become a
|
|
6
|
+
* version, and four views — 试玩 (stage + parameter drawer),
|
|
7
|
+
* 元素 (cards, look editor, proposals), 策划 (one-pager, pillars, chapters)
|
|
8
|
+
* and 版本 (checks, timeline, log, export). Without one: how to start, the
|
|
9
|
+
* style gallery, and the existing projects.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
12
|
+
import { PanelHeader, PanelRoot, UnderlineTabs, } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
|
|
13
|
+
import { Switch, Tag } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
14
|
+
import { artStyleLabel, artStyleName } from "../project.js";
|
|
15
|
+
import { exportHtml } from "../vfs.js";
|
|
16
|
+
import { failureOf } from "./contract.js";
|
|
17
|
+
import { DesignView } from "./DesignView.js";
|
|
18
|
+
import { ElementsView } from "./ElementsView.js";
|
|
19
|
+
import { PlayView } from "./PlayView.js";
|
|
20
|
+
import { ProjectSwitcher } from "./ProjectSwitcher.js";
|
|
21
|
+
import { StyleGallery } from "./StyleGallery.js";
|
|
22
|
+
import { VersionsView } from "./VersionsView.js";
|
|
23
|
+
import css from './GameTab.module.css';
|
|
24
|
+
const RefreshIcon = _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M13 8a5 5 0 1 1-1.6-3.7M13 3v2.6h-2.6", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }) });
|
|
25
|
+
const SwitchIcon = _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M3 5h9l-2-2M13 11H4l2 2", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }) });
|
|
26
|
+
const PaletteIcon = _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("path", { d: "M8 2.5a5.5 5.5 0 1 0 0 11c.8 0 1.2-.6 1-1.3-.3-.8.2-1.7 1.1-1.7h1.2a2.2 2.2 0 0 0 2.2-2.2C13.5 5 11 2.5 8 2.5Z", stroke: "currentColor", strokeWidth: "1.2" }), _jsx("circle", { cx: "5.5", cy: "7", r: ".9", fill: "currentColor" }), _jsx("circle", { cx: "8", cy: "5", r: ".9", fill: "currentColor" }), _jsx("circle", { cx: "10.6", cy: "6.3", r: ".9", fill: "currentColor" })] });
|
|
27
|
+
const PlusIcon = _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M8 3v10M3 8h10", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round" }) });
|
|
28
|
+
const ExportIcon = _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M8 2.5v7M5 6.5l3 3 3-3M3 11v2.5h10V11", stroke: "currentColor", strokeWidth: "1.3", strokeLinecap: "round", strokeLinejoin: "round" }) });
|
|
29
|
+
/** The game tab body. */
|
|
30
|
+
export function GameTab(props) {
|
|
31
|
+
const { sessionId, t, api, insertDraft, subscribeSessionEvents, touch, subscribeNav, takeNav, reveal } = props;
|
|
32
|
+
const [state, setState] = useState(null);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
const [rev, setRev] = useState(0);
|
|
35
|
+
const [view, setView] = useState('play');
|
|
36
|
+
const [picking, setPicking] = useState(false);
|
|
37
|
+
const [styling, setStyling] = useState(false);
|
|
38
|
+
const [openElement, setOpenElement] = useState(null);
|
|
39
|
+
const [toastText, setToastText] = useState(null);
|
|
40
|
+
const [openChapter, setOpenChapter] = useState(null);
|
|
41
|
+
useEffect(() => reveal.mount(), [reveal]);
|
|
42
|
+
const toastTimer = useRef();
|
|
43
|
+
const refresh = useCallback(() => { touch(sessionId); }, [touch, sessionId]);
|
|
44
|
+
const toast = useCallback((text) => {
|
|
45
|
+
setToastText(text);
|
|
46
|
+
clearTimeout(toastTimer.current);
|
|
47
|
+
toastTimer.current = setTimeout(() => { setToastText(null); }, 2600);
|
|
48
|
+
}, []);
|
|
49
|
+
useEffect(() => subscribeSessionEvents(id => { if (id === sessionId)
|
|
50
|
+
setRev(n => n + 1); }), [subscribeSessionEvents, sessionId]);
|
|
51
|
+
// Jumps from the turn card: follow them, including one made before this tab mounted.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const go = (target) => {
|
|
54
|
+
setPicking(false);
|
|
55
|
+
setStyling(false);
|
|
56
|
+
setView(target.view);
|
|
57
|
+
setOpenElement(target.elementId ?? null);
|
|
58
|
+
setOpenChapter(target.chapterId ?? null);
|
|
59
|
+
setRev(n => n + 1);
|
|
60
|
+
};
|
|
61
|
+
const parked = takeNav();
|
|
62
|
+
if (parked !== null)
|
|
63
|
+
go(parked);
|
|
64
|
+
return subscribeNav(go);
|
|
65
|
+
}, [subscribeNav, takeNav]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let live = true;
|
|
68
|
+
void api.state(sessionId).then(result => {
|
|
69
|
+
if (!live)
|
|
70
|
+
return;
|
|
71
|
+
if (result.ok) {
|
|
72
|
+
setState(result.value);
|
|
73
|
+
setError(null);
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
setError(result.error.message);
|
|
77
|
+
}).catch((e) => { if (live)
|
|
78
|
+
setError(String(e)); });
|
|
79
|
+
return () => { live = false; };
|
|
80
|
+
}, [api, sessionId, rev]);
|
|
81
|
+
const viewProps = {
|
|
82
|
+
t, sessionId, api, refresh, toast, insertDraft: text => insertDraft(sessionId, text), insertRef: ref => props.insertRef(sessionId, ref), reveal,
|
|
83
|
+
};
|
|
84
|
+
const project = state?.bound ?? null;
|
|
85
|
+
const choose = (projectId) => {
|
|
86
|
+
if (projectId !== null && projectId === project?.id) {
|
|
87
|
+
setPicking(false);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
void api.bind(sessionId, projectId).then(() => {
|
|
91
|
+
setPicking(false);
|
|
92
|
+
setView('play');
|
|
93
|
+
setOpenElement(null);
|
|
94
|
+
if (projectId !== null)
|
|
95
|
+
toast(t('toast.switched'));
|
|
96
|
+
refresh();
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
const startNew = () => {
|
|
100
|
+
if (insertDraft(sessionId, project === null ? t('draft.start') : t('draft.newProject')))
|
|
101
|
+
toast(t('toast.newDrafted'));
|
|
102
|
+
};
|
|
103
|
+
const foot = toastText !== null && _jsx("div", { className: css.toast, role: "status", children: toastText });
|
|
104
|
+
if (state === null) {
|
|
105
|
+
return _jsx(PanelRoot, { className: css.root, children: _jsx("div", { className: css.emptyCard, children: error === null ? t('loading') : t('error', { error }) }) });
|
|
106
|
+
}
|
|
107
|
+
// A project's own state left over from before a switch is not this project's.
|
|
108
|
+
if (project === null || picking) {
|
|
109
|
+
return _jsxs(PanelRoot, { className: css.root, children: [_jsx(PanelHeader, { title: project === null ? t('home.title') : t('pick.title'), moreLabel: t('more'), meta: project !== null ? t('pick.current', { title: project.title }) : undefined, ...(picking ? { onBack: () => { setPicking(false); }, backLabel: t('common.back') } : {}), actions: [{ id: 'refresh', label: t('refresh'), icon: RefreshIcon, onSelect: refresh }] }), _jsxs("div", { className: css.scroll, children: [project === null && _jsxs("section", { className: css.hero, children: [_jsx("span", { className: css.eyebrow, children: t('home.eyebrow') }), _jsx("h3", { className: css.heroTitle, children: t('home.headline') }), _jsxs("ol", { className: css.steps, children: [_jsxs("li", { children: [_jsx("strong", { children: t('home.step1') }), _jsx("span", { children: t('home.step1d') })] }), _jsxs("li", { children: [_jsx("strong", { children: t('home.step2') }), _jsx("span", { children: t('home.step2d') })] }), _jsxs("li", { children: [_jsx("strong", { children: t('home.step3') }), _jsx("span", { children: t('home.step3d') })] })] }), _jsx("button", { type: "button", className: css.primary, onClick: startNew, children: t('home.start') })] }), project === null && _jsxs("section", { className: css.block, children: [_jsx("h4", { className: css.blockTitle, children: t('style.galleryTitle') }), _jsx("p", { className: css.metaLine, children: t('style.galleryHint') }), _jsx(StyleGallery, { t: t, onPick: (_id, label) => { if (insertDraft(sessionId, t('draft.pickStyle', { label })))
|
|
110
|
+
toast(t('toast.drafted')); } })] }), (picking || state.projects.length > 0) && _jsxs("section", { className: css.block, children: [_jsx("h4", { className: css.blockTitle, children: picking ? t('pick.all', { count: state.projects.length }) : t('pick.existing') }), _jsx(ProjectSwitcher, { ...viewProps, currentId: project?.id ?? null, rev: rev, onChoose: choose, onNew: startNew }), project !== null && _jsx("button", { type: "button", className: css.link, onClick: () => { choose(null); }, children: t('pick.unbind') })] })] }), foot] });
|
|
111
|
+
}
|
|
112
|
+
return _jsx(ProjectWorkbench, { ...viewProps, project: project, view: view, setView: v => { setView(v); if (v !== 'elements')
|
|
113
|
+
setOpenElement(null); if (v !== 'design')
|
|
114
|
+
setOpenChapter(null); }, openChapter: openChapter, setOpenChapter: setOpenChapter, styling: styling, setStyling: setStyling, openElement: openElement, setOpenElement: setOpenElement, onPickProject: () => { setPicking(true); }, foot: foot });
|
|
115
|
+
}
|
|
116
|
+
/** The bound-project workbench. */
|
|
117
|
+
function ProjectWorkbench(p) {
|
|
118
|
+
const { t, sessionId, api, project, view, setView, styling, setStyling, refresh, toast, insertDraft, reveal } = p;
|
|
119
|
+
const [, rerender] = useState(0);
|
|
120
|
+
useEffect(() => reveal.subscribe(() => { rerender(n => n + 1); }), [reveal]);
|
|
121
|
+
const notice = reveal.notice;
|
|
122
|
+
const autoConfirm = project.manifest?.autoConfirm ?? true;
|
|
123
|
+
const style = project.manifest?.artStyle;
|
|
124
|
+
const exportCurrent = () => {
|
|
125
|
+
if (project.current === null) {
|
|
126
|
+
toast(t('versions.exportNone'));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const version = project.current.version;
|
|
130
|
+
void api.versionBundle(project.id, version).then(r => {
|
|
131
|
+
if (!(r.ok && r.value.ok))
|
|
132
|
+
return;
|
|
133
|
+
const url = URL.createObjectURL(new Blob([exportHtml(project.title, r.value.bundle.files)], { type: 'text/html' }));
|
|
134
|
+
const a = document.createElement('a');
|
|
135
|
+
a.href = url;
|
|
136
|
+
a.download = `${project.title}-v${version}.html`;
|
|
137
|
+
a.click();
|
|
138
|
+
setTimeout(() => { URL.revokeObjectURL(url); }, 2000);
|
|
139
|
+
toast(t('toast.exported', { version }));
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const actions = [
|
|
143
|
+
{ id: 'refresh', label: t('refresh'), icon: RefreshIcon, onSelect: refresh },
|
|
144
|
+
{ id: 'style', label: t('style.change'), icon: PaletteIcon, onSelect: () => { setStyling(!styling); }, pressed: styling },
|
|
145
|
+
{ id: 'export', label: t('versions.exportCurrent'), icon: ExportIcon, onSelect: exportCurrent, menuOnly: true },
|
|
146
|
+
{ id: 'switch', label: t('switch'), icon: SwitchIcon, onSelect: p.onPickProject, menuOnly: true },
|
|
147
|
+
{ id: 'new', label: t('switcher.new'), icon: PlusIcon, onSelect: () => { if (insertDraft(t('draft.newProject')))
|
|
148
|
+
toast(t('toast.newDrafted')); }, menuOnly: true },
|
|
149
|
+
];
|
|
150
|
+
const failing = project.lastCheck !== null && !project.lastCheck.ok;
|
|
151
|
+
const openProposals = project.proposals.filter(x => x.picked === undefined).length;
|
|
152
|
+
// Any touch in the panel means the user is busy here: the agent's request waits as a notice.
|
|
153
|
+
return _jsxs(PanelRoot, { className: css.root, onPointerDownCapture: () => { reveal.touched(); }, onKeyDownCapture: () => { reveal.touched(); }, children: [_jsx(PanelHeader, { title: _jsxs("button", { type: "button", className: css.titleSwitch, onClick: p.onPickProject, title: t('switch'), "aria-haspopup": "dialog", children: [_jsx("span", { children: project.title }), _jsx("span", { className: css.titleCaret, "aria-hidden": "true", children: "\u25BE" })] }), meta: _jsxs("span", { className: css.headMeta, children: [style !== undefined && _jsx("span", { title: artStyleLabel(style), children: _jsx(Tag, { tone: "quiet", children: artStyleName(style) }) }), _jsx(Tag, { tone: "quiet", children: project.current === null ? t('meta.noVersion') : t('meta.version', { version: project.current.version }) })] }), extra: _jsx("span", { className: css.switches, children: _jsxs("span", { className: css.switch, title: autoConfirm ? t('autoConfirm.onHint') : t('autoConfirm.offHint'), children: [_jsx(Switch, { checked: autoConfirm, label: t('autoConfirm.label'), onChange: next => { void api.setAutoConfirm(sessionId, project.id, next).then(() => { toast(next ? t('toast.autoOn') : t('toast.autoOff')); refresh(); }); } }), _jsx("span", { className: css.switchLabel, children: t('autoConfirm.label') })] }) }), actions: actions, moreLabel: t('more') }), _jsx(UnderlineTabs, { label: t('tabs.label'), value: view, onChange: setView, tabs: [
|
|
154
|
+
{ id: 'play', label: t('tabs.play'), badge: project.candidate !== null ? '●' : undefined },
|
|
155
|
+
{ id: 'elements', label: t('tabs.elements'), badge: project.elementErrors.length > 0 ? '!' : openProposals > 0 ? '●' : project.elements.length || undefined },
|
|
156
|
+
{ id: 'design', label: t('tabs.design') },
|
|
157
|
+
{ id: 'versions', label: t('tabs.versions'), badge: failing ? '!' : undefined },
|
|
158
|
+
] }), styling && _jsxs("div", { className: css.overlay, children: [_jsxs("div", { className: css.overlayHead, children: [_jsx("strong", { children: t('style.restyleTitle') }), _jsx("span", { className: css.spacer }), _jsx("button", { type: "button", className: css.ghost, onClick: () => { setStyling(false); }, children: t('common.close') })] }), _jsx("p", { className: css.metaLine, children: t('style.restyleHint') }), _jsx(StyleGallery, { t: t, current: style, onPick: (id, label) => {
|
|
159
|
+
if (id === style)
|
|
160
|
+
return;
|
|
161
|
+
void api.requestRestyle(sessionId, project.id, id).then(() => {
|
|
162
|
+
if (insertDraft(t('draft.restyle', { label })))
|
|
163
|
+
toast(t('toast.restyleDrafted'));
|
|
164
|
+
setStyling(false);
|
|
165
|
+
refresh();
|
|
166
|
+
});
|
|
167
|
+
} })] }), _jsx(CommitBar, { ...p, project: project, onOpenVersions: () => { setView('versions'); } }), notice !== null && _jsxs("div", { className: css.navNotice, role: "status", children: [_jsx("span", { className: css.navNoticeText, children: notice.text }), _jsx("button", { type: "button", className: css.primary, onClick: () => { reveal.follow(); }, children: t('nav.go') }), _jsx("button", { type: "button", className: css.link, onClick: () => { reveal.dismiss(); }, children: t('nav.dismiss') })] }), view === 'play' && _jsx(PlayView, { ...p, onOpenElement: id => { p.setOpenElement(id); setView('elements'); }, onOpenVersions: () => { setView('versions'); } }), view === 'elements' && _jsx(ElementsView, { ...p, openId: p.openElement, onOpen: p.setOpenElement }), view === 'design' && _jsx(DesignView, { ...p, openChapter: p.openChapter, onOpenChapter: p.setOpenChapter, onStyle: () => { setStyling(true); } }), view === 'versions' && _jsx(VersionsView, { ...p }), p.foot] });
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 有新改动还没进游戏: whatever the working files hold that the play area
|
|
171
|
+
* does not — panel edits, the agent's or a CLI member's writes, the user's
|
|
172
|
+
* own editor — with 检查并发版 to cut a version through the same checks
|
|
173
|
+
* (published by the user). It polls while the page is visible, which is how
|
|
174
|
+
* a member's writes show up without a turn. While someone is still writing
|
|
175
|
+
* (a turn runs, or a file changed in the last 20 s) the button is disabled.
|
|
176
|
+
*/
|
|
177
|
+
function CommitBar({ t, sessionId, api, project, refresh, toast, insertDraft, onOpenVersions }) {
|
|
178
|
+
const [changes, setChanges] = useState(null);
|
|
179
|
+
const [checking, setChecking] = useState(false);
|
|
180
|
+
const [showFiles, setShowFiles] = useState(false);
|
|
181
|
+
const [tick, setTick] = useState(0);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
let live = true;
|
|
184
|
+
void api.changes(project.id).then(r => { if (live && r.ok && r.value.ok)
|
|
185
|
+
setChanges(r.value); });
|
|
186
|
+
return () => { live = false; };
|
|
187
|
+
}, [api, project, tick]);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
const id = setInterval(() => { if (document.visibilityState === 'visible')
|
|
190
|
+
setTick(n => n + 1); }, POLL_MS);
|
|
191
|
+
return () => { clearInterval(id); };
|
|
192
|
+
}, []);
|
|
193
|
+
// A 'recent' writer goes quiet on its own: look again right after.
|
|
194
|
+
const lastAt = changes?.lastAt ?? null;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (changes?.busy !== 'recent' || lastAt === null)
|
|
197
|
+
return;
|
|
198
|
+
const timer = setTimeout(() => { setTick(n => n + 1); }, Math.max(500, Date.parse(lastAt) + QUIET_MS + 500 - Date.now()));
|
|
199
|
+
return () => { clearTimeout(timer); };
|
|
200
|
+
}, [changes?.busy, lastAt]);
|
|
201
|
+
useEffect(() => { setShowFiles(false); }, [project.id]);
|
|
202
|
+
if (changes === null || !changes.hasEntry || (changes.count === 0 && changes.edits.length === 0))
|
|
203
|
+
return null;
|
|
204
|
+
const commit = () => {
|
|
205
|
+
setChecking(true);
|
|
206
|
+
void api.commit(sessionId, project.id).then(r => {
|
|
207
|
+
setChecking(false);
|
|
208
|
+
const code = failureOf(r);
|
|
209
|
+
if (code !== undefined || !r.ok || !r.value.ok) {
|
|
210
|
+
toast(t('commit.error', { error: code ?? 'io' }));
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const result = r.value;
|
|
214
|
+
if (result.kind === 'deployed')
|
|
215
|
+
toast(t('commit.deployed', { version: result.version }));
|
|
216
|
+
else if (result.kind === 'candidate')
|
|
217
|
+
toast(t('commit.candidate', { version: result.version }));
|
|
218
|
+
else if (result.kind === 'unchanged')
|
|
219
|
+
toast(t('commit.unchanged'));
|
|
220
|
+
else if (result.kind === 'no-entry')
|
|
221
|
+
toast(t('commit.noEntry'));
|
|
222
|
+
setTick(n => n + 1);
|
|
223
|
+
refresh();
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
const live = changes.against;
|
|
227
|
+
const editList = changes.edits.slice(0, 3).map(e => e.text).join('、') + (changes.edits.length > 3 ? t('commit.more', { count: changes.edits.length - 3 }) : '');
|
|
228
|
+
const summary = [
|
|
229
|
+
changes.count > 0 ? (live === null ? t('commit.firstSummary', { count: changes.count }) : t('commit.summary', { count: changes.count, ago: agoText(t, changes.lastAt) })) : '',
|
|
230
|
+
changes.edits.length > 0 ? t('commit.edits', { list: editList }) : '',
|
|
231
|
+
].filter(Boolean).join(' · ');
|
|
232
|
+
const state = checking ? 'checking' : changes.busy !== null ? 'busy' : changes.failed !== null ? 'failed' : 'ready';
|
|
233
|
+
const newest = changes.files.find(f => f.kind !== 'removed')?.path ?? changes.files[0]?.path ?? '';
|
|
234
|
+
return _jsxs(_Fragment, { children: [_jsxs("div", { className: css.commitBar, "data-state": state, role: "status", children: [_jsxs("span", { className: css.commitText, children: [_jsx("strong", { children: state === 'checking' ? t('commit.checking')
|
|
235
|
+
: state === 'busy' ? t('commit.busyTitle')
|
|
236
|
+
: state === 'failed' ? (live === null ? t('commit.failedFirst') : t('commit.failed', { version: live }))
|
|
237
|
+
: live === null ? t('commit.titleFirst') : t('commit.title') }), _jsx("span", { className: css.commitList, children: state === 'checking' ? t('commit.checkingSteps')
|
|
238
|
+
: state === 'busy' ? (changes.busy === 'turn' ? t('commit.busyTurn') : t('commit.busyRecent', { ago: agoText(t, changes.lastAt), file: newest }))
|
|
239
|
+
: state === 'failed' ? (changes.failed ?? []).slice(0, 2).map(f => `${f.where}:${f.message}`).join(';')
|
|
240
|
+
: summary })] }), _jsxs("span", { className: css.commitActions, children: [state === 'failed' && _jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: css.link, onClick: onOpenVersions, children: t('commit.details') }), _jsx("button", { type: "button", className: css.ghost, onClick: () => {
|
|
241
|
+
if (insertDraft(t('draft.commitFailed', { list: (changes.failed ?? []).map(f => `- ${f.where}:${f.message}`).join('\n') })))
|
|
242
|
+
toast(t('toast.drafted'));
|
|
243
|
+
}, children: t('commit.toAgent') })] }), state !== 'checking' && changes.count > 0 && _jsx("button", { type: "button", className: css.link, "aria-expanded": showFiles, onClick: () => { setShowFiles(!showFiles); }, children: showFiles ? t('commit.hideFiles') : t('commit.showFiles') }), _jsx("button", { type: "button", className: css.primary, disabled: state === 'checking' || state === 'busy', onClick: commit, children: state === 'failed' ? t('commit.retry') : t('commit.go') })] })] }), showFiles && state !== 'checking' && _jsxs("div", { className: css.commitFiles, children: [changes.files.map(f => _jsxs("div", { children: [_jsx("span", { className: css.commitKind, children: f.kind === 'added' ? 'A' : f.kind === 'removed' ? 'D' : 'M' }), f.path] }, f.path)), changes.count > changes.files.length && _jsx("div", { children: t('commit.moreFiles', { count: changes.count - changes.files.length }) })] })] });
|
|
244
|
+
}
|
|
245
|
+
/** How often the bar looks at the working files while the page is visible. */
|
|
246
|
+
const POLL_MS = 8_000;
|
|
247
|
+
/** Matches the host's quiet window for a 'recent' writer (store BUSY_QUIET_MS). */
|
|
248
|
+
const QUIET_MS = 20_000;
|
|
249
|
+
/** 「12 秒前」-style age of an ISO time. */
|
|
250
|
+
function agoText(t, at) {
|
|
251
|
+
if (at === null)
|
|
252
|
+
return '';
|
|
253
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(at)) / 1000));
|
|
254
|
+
if (s < 5)
|
|
255
|
+
return t('ago.now');
|
|
256
|
+
if (s < 60)
|
|
257
|
+
return t('ago.s', { n: s });
|
|
258
|
+
if (s < 3600)
|
|
259
|
+
return t('ago.m', { n: Math.round(s / 60) });
|
|
260
|
+
return t('ago.h', { n: Math.round(s / 3600) });
|
|
261
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The play frame: one version in a `sandbox="allow-scripts"` iframe (an
|
|
3
|
+
* opaque origin — no cookies, no host storage, no network by the frame's own
|
|
4
|
+
* CSP), fed by postMessage through the shared loader. Saves live in this
|
|
5
|
+
* browser's localStorage per project; slider values are pushed in live
|
|
6
|
+
* (element edits wait for a committed version); the pick overlay asks the game what is under a click.
|
|
7
|
+
* When a play session ends (version change, restart, unmount) its length and
|
|
8
|
+
* errors go to the playtest log.
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactNode } from 'react';
|
|
11
|
+
import type { BundleFile } from '../types.ts';
|
|
12
|
+
import { type PickRef } from '../vfs.ts';
|
|
13
|
+
/** Forget a project's saves (重开存档). */
|
|
14
|
+
export declare function clearSaves(projectId: string): void;
|
|
15
|
+
/** Play-session report handed to the playtest log. */
|
|
16
|
+
export interface PlayReport {
|
|
17
|
+
readonly version: number;
|
|
18
|
+
readonly seconds: number;
|
|
19
|
+
readonly errors: string[];
|
|
20
|
+
}
|
|
21
|
+
/** One answered pick: the game's reference (a bare id becomes `{ id }`), and how the click was made. */
|
|
22
|
+
export interface PickAnswer {
|
|
23
|
+
readonly ref: PickRef | null;
|
|
24
|
+
readonly supported: boolean;
|
|
25
|
+
/** Shift held: keep picking (several references in one message). */
|
|
26
|
+
readonly shift: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Rings for the candidate's changes, as the game's `highlight` takes them. */
|
|
29
|
+
export type HighlightList = readonly {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly tone: 'changed' | 'added';
|
|
32
|
+
}[];
|
|
33
|
+
/** Props of the play frame. */
|
|
34
|
+
export interface PlayFrameProps {
|
|
35
|
+
readonly projectId: string;
|
|
36
|
+
readonly version: number;
|
|
37
|
+
readonly files: Readonly<Record<string, BundleFile>>;
|
|
38
|
+
/** Restart counter: bump to reload the frame. */
|
|
39
|
+
readonly run: number;
|
|
40
|
+
/** Live slider values (key → value), pushed on every change. */
|
|
41
|
+
readonly params: Readonly<Record<string, number>>;
|
|
42
|
+
readonly picking: boolean;
|
|
43
|
+
readonly onPicked: (answer: PickAnswer) => void;
|
|
44
|
+
/** The hover hint's text for what is under the pointer while picking. */
|
|
45
|
+
readonly hoverLabel: (ref: PickRef) => string;
|
|
46
|
+
/** Candidate diff rings; null clears them. */
|
|
47
|
+
readonly highlight: HighlightList | null;
|
|
48
|
+
readonly onErrors: (errors: readonly string[]) => void;
|
|
49
|
+
readonly onReport: (report: PlayReport) => void;
|
|
50
|
+
readonly label: string;
|
|
51
|
+
}
|
|
52
|
+
/** The sandboxed frame with a pick overlay. */
|
|
53
|
+
export declare function PlayFrame({ projectId, version, files, run, params, picking, onPicked, hoverLabel, highlight, onErrors, onReport, label }: PlayFrameProps): ReactNode;
|
|
54
|
+
//# sourceMappingURL=PlayFrame.d.ts.map
|