@khorsheed/dsh-game 0.0.0-stage → 0.1.0

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.en.md +53 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +52 -2
  5. package/cordis.patch.yml +23 -0
  6. package/lib/client.js +30348 -0
  7. package/lib/index.js +4154 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +3769 -0
  10. package/lib/typert.remote-client.d.ts +101 -0
  11. package/lib/typert.remote-client.js +2082 -0
  12. package/lib/types/agent.d.ts +20 -0
  13. package/lib/types/agent.js +42 -0
  14. package/lib/types/checks.d.ts +44 -0
  15. package/lib/types/checks.js +267 -0
  16. package/lib/types/client/DesignView.d.ts +22 -0
  17. package/lib/types/client/DesignView.js +134 -0
  18. package/lib/types/client/ElementsView.d.ts +21 -0
  19. package/lib/types/client/ElementsView.js +187 -0
  20. package/lib/types/client/GameTab.d.ts +14 -0
  21. package/lib/types/client/GameTab.js +256 -0
  22. package/lib/types/client/PlayFrame.d.ts +54 -0
  23. package/lib/types/client/PlayFrame.js +129 -0
  24. package/lib/types/client/PlayView.d.ts +19 -0
  25. package/lib/types/client/PlayView.js +161 -0
  26. package/lib/types/client/Preview3D.d.ts +50 -0
  27. package/lib/types/client/Preview3D.js +176 -0
  28. package/lib/types/client/ProjectSwitcher.d.ts +20 -0
  29. package/lib/types/client/ProjectSwitcher.js +75 -0
  30. package/lib/types/client/Recipe3DEditor.d.ts +19 -0
  31. package/lib/types/client/Recipe3DEditor.js +44 -0
  32. package/lib/types/client/StyleGallery.d.ts +18 -0
  33. package/lib/types/client/StyleGallery.js +27 -0
  34. package/lib/types/client/TurnCard.d.ts +62 -0
  35. package/lib/types/client/TurnCard.js +100 -0
  36. package/lib/types/client/VersionsView.d.ts +17 -0
  37. package/lib/types/client/VersionsView.js +68 -0
  38. package/lib/types/client/chat-node.d.ts +42 -0
  39. package/lib/types/client/chat-node.js +77 -0
  40. package/lib/types/client/contract.d.ts +125 -0
  41. package/lib/types/client/contract.js +6 -0
  42. package/lib/types/client/definition.d.ts +22 -0
  43. package/lib/types/client/definition.js +28 -0
  44. package/lib/types/client/index.d.ts +21 -0
  45. package/lib/types/client/index.js +229 -0
  46. package/lib/types/client/locales.d.ts +376 -0
  47. package/lib/types/client/locales.js +727 -0
  48. package/lib/types/client/looks.d.ts +36 -0
  49. package/lib/types/client/looks.js +105 -0
  50. package/lib/types/client/markdown.d.ts +11 -0
  51. package/lib/types/client/markdown.js +52 -0
  52. package/lib/types/client/preset-visibility.d.ts +94 -0
  53. package/lib/types/client/preset-visibility.js +115 -0
  54. package/lib/types/client/preview-entry.d.ts +24 -0
  55. package/lib/types/client/preview-entry.js +107 -0
  56. package/lib/types/client/refs.d.ts +36 -0
  57. package/lib/types/client/refs.js +44 -0
  58. package/lib/types/client/style-samples.d.ts +5 -0
  59. package/lib/types/client/style-samples.js +196 -0
  60. package/lib/types/fsutil.d.ts +26 -0
  61. package/lib/types/fsutil.js +104 -0
  62. package/lib/types/hooks.d.ts +83 -0
  63. package/lib/types/hooks.js +108 -0
  64. package/lib/types/index.d.ts +30 -0
  65. package/lib/types/index.js +22 -0
  66. package/lib/types/kits/canvas2d.d.ts +6 -0
  67. package/lib/types/kits/canvas2d.js +60 -0
  68. package/lib/types/kits/ids.d.ts +11 -0
  69. package/lib/types/kits/ids.js +6 -0
  70. package/lib/types/kits/index.d.ts +26 -0
  71. package/lib/types/kits/index.js +28 -0
  72. package/lib/types/kits/three3d.d.ts +12 -0
  73. package/lib/types/kits/three3d.js +188 -0
  74. package/lib/types/kits/types.d.ts +52 -0
  75. package/lib/types/kits/types.js +6 -0
  76. package/lib/types/look3d.d.ts +20 -0
  77. package/lib/types/look3d.js +175 -0
  78. package/lib/types/panel-session.d.ts +21 -0
  79. package/lib/types/panel-session.js +16 -0
  80. package/lib/types/project.d.ts +132 -0
  81. package/lib/types/project.js +494 -0
  82. package/lib/types/remote.d.ts +182 -0
  83. package/lib/types/remote.js +256 -0
  84. package/lib/types/skills.d.ts +13 -0
  85. package/lib/types/skills.js +140 -0
  86. package/lib/types/store.d.ts +325 -0
  87. package/lib/types/store.js +1285 -0
  88. package/lib/types/tools.d.ts +12 -0
  89. package/lib/types/tools.js +301 -0
  90. package/lib/types/trash.d.ts +5 -0
  91. package/lib/types/trash.js +64 -0
  92. package/lib/types/types.d.ts +522 -0
  93. package/lib/types/types.js +85 -0
  94. package/lib/types/vfs.d.ts +92 -0
  95. package/lib/types/vfs.js +217 -0
  96. package/locale/en.json +6 -0
  97. package/locale/zh.json +6 -0
  98. package/package.json +132 -3
  99. package/runtime/dsh3d.js +1279 -0
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The play frame: one version in a `sandbox="allow-scripts"` iframe (an
4
+ * opaque origin — no cookies, no host storage, no network by the frame's own
5
+ * CSP), fed by postMessage through the shared loader. Saves live in this
6
+ * browser's localStorage per project; slider values are pushed in live
7
+ * (element edits wait for a committed version); the pick overlay asks the game what is under a click.
8
+ * When a play session ends (version change, restart, unmount) its length and
9
+ * errors go to the playtest log.
10
+ */
11
+ import { useEffect, useMemo, useRef, useState } from 'react';
12
+ import { shellHtml } from "../vfs.js";
13
+ import css from './GameTab.module.css';
14
+ /** localStorage key for one project's saves. */
15
+ const savesKey = (projectId) => `dsh-game:saves:${projectId}`;
16
+ function readSaves(projectId) {
17
+ try {
18
+ const raw = localStorage.getItem(savesKey(projectId));
19
+ const parsed = raw === null ? {} : JSON.parse(raw);
20
+ return parsed !== null && typeof parsed === 'object' ? parsed : {};
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function writeSaves(projectId, saves) {
27
+ try {
28
+ localStorage.setItem(savesKey(projectId), JSON.stringify(saves));
29
+ }
30
+ catch {
31
+ // Storage blocked or full: the game keeps its in-frame copy for this session.
32
+ }
33
+ }
34
+ /** Forget a project's saves (重开存档). */
35
+ export function clearSaves(projectId) {
36
+ try {
37
+ localStorage.removeItem(savesKey(projectId));
38
+ }
39
+ catch {
40
+ // Nothing to clear.
41
+ }
42
+ }
43
+ /** The sandboxed frame with a pick overlay. */
44
+ export function PlayFrame({ projectId, version, files, run, params, picking, onPicked, hoverLabel, highlight, onErrors, onReport, label }) {
45
+ const frame = useRef(null);
46
+ const shell = useMemo(() => shellHtml(), []);
47
+ const report = useRef(onReport);
48
+ report.current = onReport;
49
+ const errorsCb = useRef(onErrors);
50
+ errorsCb.current = onErrors;
51
+ const pickedCb = useRef(onPicked);
52
+ pickedCb.current = onPicked;
53
+ const paramsRef = useRef(params);
54
+ paramsRef.current = params;
55
+ const [booted, setBooted] = useState(false);
56
+ const [hover, setHover] = useState(null);
57
+ const hoverLabelRef = useRef(hoverLabel);
58
+ hoverLabelRef.current = hoverLabel;
59
+ const shiftRef = useRef(false);
60
+ const pointer = useRef({ x: 0, y: 0 });
61
+ const hoverTimer = useRef(0);
62
+ useEffect(() => {
63
+ const started = Date.now();
64
+ const seen = [];
65
+ errorsCb.current([]);
66
+ setBooted(false);
67
+ const onMessage = (event) => {
68
+ if (event.source !== frame.current?.contentWindow)
69
+ return;
70
+ const data = event.data;
71
+ if (data?.gamedev === 'error') {
72
+ if (seen.length < 50)
73
+ seen.push(data.msg);
74
+ errorsCb.current([...seen]);
75
+ }
76
+ else if (data?.gamedev === 'save') {
77
+ writeSaves(projectId, data.saves);
78
+ }
79
+ else if (data?.gamedev === 'picked') {
80
+ const ref = data.ref ?? (data.id === null ? null : { id: data.id });
81
+ if (data.hover)
82
+ setHover(ref === null ? null : { text: hoverLabelRef.current(ref), ...pointer.current });
83
+ else
84
+ pickedCb.current({ ref, supported: data.supported, shift: shiftRef.current });
85
+ }
86
+ };
87
+ window.addEventListener('message', onMessage);
88
+ return () => {
89
+ window.removeEventListener('message', onMessage);
90
+ const seconds = (Date.now() - started) / 1000;
91
+ if (seconds >= 5 || seen.length > 0)
92
+ report.current({ version, seconds, errors: seen });
93
+ };
94
+ }, [projectId, version, run]);
95
+ const post = (message) => { frame.current?.contentWindow?.postMessage(message, '*'); };
96
+ useEffect(() => { if (booted)
97
+ post({ gamedev: 'params', values: params }); }, [params, booted]);
98
+ // 「指一下」 freezes the game (games that implement pause); leaving it resumes.
99
+ useEffect(() => { if (booted)
100
+ post({ gamedev: 'pause', on: picking }); if (!picking)
101
+ setHover(null); }, [picking, booted]);
102
+ const highlightKey = JSON.stringify(highlight);
103
+ useEffect(() => { if (booted)
104
+ post({ gamedev: 'highlight', list: highlight ?? [] }); }, [highlightKey, booted]); // eslint-disable-line react-hooks/exhaustive-deps -- the key stands for the list
105
+ const boot = () => {
106
+ if (booted)
107
+ return;
108
+ post({ gamedev: 'files', files, saves: readSaves(projectId), params: paramsRef.current });
109
+ setBooted(true);
110
+ };
111
+ const pick = (event) => {
112
+ const rect = event.currentTarget.getBoundingClientRect();
113
+ shiftRef.current = event.shiftKey;
114
+ post({ gamedev: 'pick', x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.height });
115
+ };
116
+ // Hover asks the game too (throttled), so the user sees what a click would pick.
117
+ const hoverAt = (event) => {
118
+ const rect = event.currentTarget.getBoundingClientRect();
119
+ pointer.current = { x: event.clientX - rect.left, y: event.clientY - rect.top };
120
+ const now = Date.now();
121
+ if (now - hoverTimer.current < 90)
122
+ return;
123
+ hoverTimer.current = now;
124
+ post({ gamedev: 'pick', hover: true, x: pointer.current.x / rect.width, y: pointer.current.y / rect.height });
125
+ };
126
+ return _jsxs("div", { className: css.stageInner, "data-picking": picking ? 'true' : 'false', children: [_jsx("iframe", { ref: frame, className: css.frame, title: label,
127
+ // Pointer lock: a game may grab the mouse for camera turns (a click asks, Esc releases).
128
+ sandbox: "allow-scripts allow-pointer-lock", srcDoc: shell, onLoad: boot }, `${version}:${run}`), picking && _jsx("div", { className: css.pickLayer, onClick: pick, onMouseMove: hoverAt, onMouseLeave: () => { setHover(null); }, role: "presentation" }), picking && hover !== null && _jsx("span", { className: css.pickHover, style: { left: hover.x, top: hover.y }, children: hover.text })] });
129
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 试玩: the stage (the version on the play area, framed like a game screen,
3
+ * with its status strip), the candidate bar when a version waits for the
4
+ * user, the last check's verdict when it failed, the pick mode, and the
5
+ * parameter drawer — sliders that change the running game at once, marked
6
+ * amber where they differ from the finalized value (定稿 / 还原).
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ import { type ProjectView } from '../types.ts';
10
+ import { type ViewProps } from './contract.ts';
11
+ /** Props of the play view. */
12
+ export interface PlayViewProps extends ViewProps {
13
+ readonly project: ProjectView;
14
+ readonly onOpenElement: (id: string) => void;
15
+ readonly onOpenVersions: () => void;
16
+ }
17
+ /** The play view. */
18
+ export declare function PlayView(props: PlayViewProps): ReactNode;
19
+ //# sourceMappingURL=PlayView.d.ts.map
@@ -0,0 +1,161 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * 试玩: the stage (the version on the play area, framed like a game screen,
4
+ * with its status strip), the candidate bar when a version waits for the
5
+ * user, the last check's verdict when it failed, the pick mode, and the
6
+ * parameter drawer — sliders that change the running game at once, marked
7
+ * amber where they differ from the finalized value (定稿 / 还原).
8
+ */
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+ import { PANEL_CHANGES_MARK } from "../types.js";
11
+ import { failureOf } from "./contract.js";
12
+ import { LookCanvas } from "./looks.js";
13
+ import { clearSaves, PlayFrame } from "./PlayFrame.js";
14
+ import { pickChip } from "./refs.js";
15
+ import css from './GameTab.module.css';
16
+ /** A 3D look's main color, for the small swatch beside a pick. */
17
+ function look3dColor(look) {
18
+ const recipe = look.kind === 'parts3d' ? look : look.recipe;
19
+ return recipe?.palette.body ?? Object.values(recipe?.palette ?? {})[0];
20
+ }
21
+ /** Round a slider value to its step for display. */
22
+ function fmt(value, step) {
23
+ const decimals = Math.max(0, Math.min(4, -Math.floor(Math.log10(step))));
24
+ return value.toFixed(decimals);
25
+ }
26
+ /** The play view. */
27
+ export function PlayView(props) {
28
+ const { t, sessionId, api, project, refresh, toast, insertDraft, insertRef, onOpenElement, onOpenVersions } = props;
29
+ const [which, setWhich] = useState('current');
30
+ const [bundle, setBundle] = useState(null);
31
+ const [error, setError] = useState(null);
32
+ const [run, setRun] = useState(0);
33
+ const [errors, setErrors] = useState([]);
34
+ const [picking, setPicking] = useState(false);
35
+ const [picked, setPicked] = useState(null);
36
+ const [diffOn, setDiffOn] = useState(true);
37
+ const [rejecting, setRejecting] = useState(false);
38
+ const [reason, setReason] = useState('');
39
+ const record = which === 'candidate' ? project.candidate : project.current;
40
+ const version = record?.version;
41
+ const loaded = useRef('');
42
+ // Playing is opt-in (2026-10-10): a big game should not start, and cost, on
43
+ // every visit. A new version stops the old run and shows its poster.
44
+ const [playing, setPlaying] = useState(false);
45
+ const [poster, setPoster] = useState(null);
46
+ useEffect(() => { setPlaying(false); setPicking(false); setPicked(null); }, [version, which]);
47
+ useEffect(() => {
48
+ let live = true;
49
+ // The poster of the version on screen (a version without its own shows none, never another build's).
50
+ void api.checkShot(project.id, version).then(r => { if (live)
51
+ setPoster(r.ok && r.value.ok ? r.value.webp : null); });
52
+ return () => { live = false; };
53
+ }, [api, project.id, version, project.lastCheck?.at]);
54
+ useEffect(() => { if (project.candidate === null && which === 'candidate')
55
+ setWhich('current'); }, [project.candidate, which]);
56
+ useEffect(() => {
57
+ if (!playing)
58
+ return;
59
+ if (version === undefined) {
60
+ setBundle(null);
61
+ loaded.current = '';
62
+ return;
63
+ }
64
+ const key = `${project.id}:${which}:${version}`;
65
+ if (loaded.current === key)
66
+ return;
67
+ let live = true;
68
+ void api.bundle(project.id, which).then(result => {
69
+ if (!live)
70
+ return;
71
+ if (result.ok && result.value.ok) {
72
+ loaded.current = key;
73
+ setBundle(result.value.bundle);
74
+ setError(null);
75
+ }
76
+ else
77
+ setError(failureOf(result) ?? 'io');
78
+ });
79
+ return () => { live = false; };
80
+ }, [api, project.id, which, version, playing]);
81
+ // Live slider values: the finalized defaults overlaid with the user's layer.
82
+ const params = project.manifest?.params ?? [];
83
+ const [local, setLocal] = useState({});
84
+ useEffect(() => { setLocal({}); }, [project.paramOverrides]);
85
+ const values = useMemo(() => Object.fromEntries(params.map(p => [p.key, local[p.key] ?? project.paramOverrides[p.key] ?? p.value])), [params, local, project.paramOverrides]);
86
+ const tuned = params.filter(p => values[p.key] !== p.value);
87
+ const commit = useRef({});
88
+ const slide = (param, value) => {
89
+ setLocal(l => ({ ...l, [param.key]: value }));
90
+ clearTimeout(commit.current[param.key]);
91
+ commit.current[param.key] = setTimeout(() => {
92
+ void api.setParam(sessionId, project.id, param.key, value).then(refresh);
93
+ }, 250);
94
+ };
95
+ const report = (r) => { void api.logPlay(sessionId, { projectId: project.id, ...r }); };
96
+ const pickedElement = picked?.id ? project.elements.find(e => e.id === picked.id) : undefined;
97
+ const nameOf = (id) => (id === null ? undefined : project.elements.find(e => e.id === id)?.name);
98
+ const chipOf = (ref) => pickChip(ref, nameOf(ref.id), t('play.ground'));
99
+ // A pick goes straight into the composer as a chip (G5); Shift keeps picking for a second reference.
100
+ const onPicked = (answer) => {
101
+ if (!answer.supported) {
102
+ toast(t('play.pickUnsupported'));
103
+ return;
104
+ }
105
+ if (answer.ref === null) {
106
+ toast(t('play.pickNothing'));
107
+ return;
108
+ }
109
+ const chip = chipOf(answer.ref);
110
+ if (insertRef(chip))
111
+ toast(t('play.inserted', { label: chip.label }));
112
+ setPicked({ id: answer.ref.ground === true ? null : answer.ref.id, label: chip.label });
113
+ if (!answer.shift)
114
+ setPicking(false);
115
+ };
116
+ const diff = project.candidateDiff;
117
+ const diffCount = diff === null ? 0 : diff.changed.length + diff.added.length;
118
+ const highlight = which === 'candidate' && diffOn && diff !== null
119
+ ? [...diff.changed.map(id => ({ id, tone: 'changed' })), ...diff.added.map(id => ({ id, tone: 'added' }))]
120
+ : null;
121
+ const check = project.lastCheck;
122
+ return _jsxs("div", { className: css.scroll, children: [check !== null && !check.ok && _jsxs("button", { type: "button", className: css.strip, "data-tone": check.gaveUp ? 'warn' : 'error', onClick: onOpenVersions, children: [_jsx("span", { className: css.stripDot, "aria-hidden": "true" }), _jsx("span", { children: check.gaveUp ? t('play.checkGaveUp') : t('play.checkFailed') }), _jsx("span", { className: css.stripMeta, children: check.failures[0]?.where }), _jsx("span", { className: css.stripGo, children: t('play.seeChecks') })] }), project.restyleTo !== undefined && _jsxs("div", { className: css.strip, "data-tone": "info", children: [_jsx("span", { className: css.stripDot, "aria-hidden": "true" }), _jsx("span", { children: t('play.restyling') })] }), _jsxs("section", { className: css.stage, "aria-label": t('tabs.play'), children: [_jsxs("div", { className: css.stageBar, children: [_jsx("span", { className: css.versionPill, "data-tone": which === 'candidate' ? 'warn' : 'ok', children: version === undefined ? t('meta.noVersion') : `${which === 'candidate' ? t('play.candidateShort') : t('play.live')} v${version}` }), _jsx("span", { className: css.stageHint, children: !playing ? t('play.idleHint') : picking ? t('play.pickHint') : t('play.focus') }), errors.length > 0 && _jsx("span", { className: css.errorPill, title: errors.slice(0, 5).join('\n'), children: t('play.errors', { count: errors.length }) }), _jsx("span", { className: css.spacer }), playing && bundle !== null && which === 'candidate' && diffCount > 0 && _jsx("button", { type: "button", className: css.stageBtn, "aria-pressed": diffOn, title: t('play.diffHint'), onClick: () => { setDiffOn(on => !on); }, children: t('play.diff') }), playing && bundle !== null && _jsx("button", { type: "button", className: css.stageBtn, "aria-pressed": picking, onClick: () => { setPicking(p => !p); setPicked(null); }, children: t('play.pick') }), playing && bundle !== null && _jsx("button", { type: "button", className: css.stageBtn, onClick: () => { setRun(n => n + 1); }, children: t('play.restart') }), playing && _jsx("button", { type: "button", className: css.stageBtn, onClick: () => { setPlaying(false); setPicking(false); setPicked(null); }, children: t('play.stop') })] }), _jsxs("div", { className: css.screen, children: [error !== null && _jsx("p", { className: css.screenNote, children: t('play.loadFailed', { error }) }), version === undefined
123
+ ? _jsxs("div", { className: css.screenEmpty, children: [_jsx("div", { className: css.screenGlyph, "aria-hidden": "true", children: "\u25B6" }), _jsx("p", { children: project.hasEntry ? t('play.none') : t('play.noEntry') })] })
124
+ : !playing
125
+ ? _jsxs("button", { type: "button", className: css.poster, onClick: () => { setPlaying(true); }, "aria-label": t('play.start', { version }), children: [poster !== null && _jsx("img", { src: `data:image/webp;base64,${poster}`, alt: "" }), _jsxs("span", { className: css.posterPlay, children: [_jsx("span", { className: css.posterIcon, "aria-hidden": "true", children: "\u25B6" }), t('play.start', { version })] })] })
126
+ : bundle !== null && _jsx(PlayFrame, { projectId: project.id, version: bundle.version, files: bundle.files, run: run, params: values, picking: picking, label: `${project.title} v${bundle.version}`, onPicked: onPicked, hoverLabel: ref => chipOf(ref).label, highlight: highlight, onErrors: setErrors, onReport: report })] }), project.candidate !== null && _jsxs("div", { className: css.candBar, "data-tone": "warn", children: [_jsxs("div", { className: css.bannerText, children: [_jsx("strong", { children: t('play.candidateTitle', { version: project.candidate.version }) }), _jsx("span", { children: project.candidate.note ?? t('play.candidate') })] }), _jsxs("div", { className: css.bannerActions, children: [_jsx("button", { type: "button", className: css.ghost, "aria-pressed": which === 'candidate', onClick: () => { setWhich(w => (w === 'candidate' ? 'current' : 'candidate')); }, children: which === 'candidate' ? t('play.backToCurrent') : t('play.tryCandidate') }), _jsx("button", { type: "button", className: css.ghost, onClick: () => { setRejecting(r => !r); }, children: t('play.reject') }), _jsx("button", { type: "button", className: css.primary, onClick: () => {
127
+ void api.confirm(sessionId, project.id).then(r => { if (!failureOf(r))
128
+ toast(t('toast.confirmed', { version: project.candidate.version })); setWhich('current'); refresh(); });
129
+ }, children: t('play.confirm') })] }), rejecting && _jsxs("div", { className: css.rejectRow, children: [_jsx("input", { className: css.input, value: reason, placeholder: t('play.rejectReason'), onChange: e => { setReason(e.target.value); } }), _jsx("button", { type: "button", className: css.ghost, onClick: () => {
130
+ const rejected = project.candidate?.version;
131
+ void api.reject(sessionId, project.id, reason).then(() => {
132
+ setRejecting(false);
133
+ setReason('');
134
+ setWhich('current');
135
+ if (rejected !== undefined)
136
+ toast(t('nudge.rejected', { version: rejected }));
137
+ refresh();
138
+ });
139
+ }, children: t('play.rejectGo') })] })] }), picked !== null && _jsxs("div", { className: css.pickCard, children: [pickedElement !== undefined && pickedElement.look !== null ? _jsx(LookCanvas, { look: pickedElement.look, box: 32 })
140
+ : _jsx("span", { className: css.swatch, style: pickedElement?.look3d != null ? { background: look3dColor(pickedElement.look3d) } : undefined }), _jsxs("span", { className: css.pickName, children: [_jsx("strong", { children: picked.label }), _jsx("span", { className: css.metaLine, children: t('play.pickedNote') })] }), pickedElement !== undefined && _jsx("button", { type: "button", className: css.ghost, onClick: () => { onOpenElement(pickedElement.id); }, children: t('play.openElement') })] })] }), params.length > 0 && _jsxs("section", { className: css.drawer, "aria-label": t('params.title'), children: [_jsxs("header", { className: css.drawerHead, children: [_jsx("strong", { children: t('params.title') }), _jsx("span", { className: css.metaLine, children: tuned.length > 0 ? t('params.changed', { count: tuned.length }) : t('params.hint') }), _jsx("span", { className: css.spacer }), _jsx("button", { type: "button", className: css.ghost, disabled: tuned.length === 0, onClick: () => {
141
+ void api.resetParams(sessionId, project.id).then(() => { setLocal({}); refresh(); });
142
+ }, children: t('params.reset') }), _jsx("button", { type: "button", className: css.ghost, disabled: tuned.length === 0, onClick: () => {
143
+ const list = tuned.map(p => `${p.label} ${fmt(p.value, p.step)}→${fmt(values[p.key] ?? p.value, p.step)}`).join('、');
144
+ if (insertDraft(`${PANEL_CHANGES_MARK}${t('params.tellLead', { list })}\n\n`))
145
+ toast(t('toast.drafted'));
146
+ }, children: t('params.tell') }), _jsx("button", { type: "button", className: css.ghost, disabled: tuned.length === 0, onClick: () => {
147
+ // 定稿 writes the defaults into game.json and commits at once: the play area moves to a version that has them.
148
+ void api.finalizeParams(sessionId, project.id).then(() => api.commit(sessionId, project.id)).then(r => {
149
+ setLocal({});
150
+ const result = failureOf(r) === undefined && r.ok && r.value.ok ? r.value : undefined;
151
+ toast(result?.kind === 'deployed' ? t('commit.deployed', { version: result.version })
152
+ : result?.kind === 'candidate' ? t('commit.candidate', { version: result.version })
153
+ : result?.kind === 'failed' ? t('commit.failedToast') : t('nudge.finalized'));
154
+ refresh();
155
+ });
156
+ }, children: t('params.finalize') })] }), params.map(p => {
157
+ const value = values[p.key] ?? p.value;
158
+ const dirty = value !== p.value;
159
+ return _jsxs("label", { className: css.param, "data-dirty": dirty ? 'true' : 'false', children: [_jsx("span", { className: css.paramLabel, children: p.label }), _jsx("input", { type: "range", min: p.min, max: p.max, step: p.step, value: value, className: css.range, onChange: e => { slide(p, Number(e.target.value)); } }), _jsxs("span", { className: css.paramValue, children: [fmt(value, p.step), p.unit === undefined ? '' : _jsxs("small", { children: [" ", p.unit] })] }), dirty && _jsx("span", { className: css.paramWas, title: t('params.was'), children: fmt(p.value, p.step) })] }, p.key);
160
+ })] }), params.length === 0 && version !== undefined && _jsx("p", { className: css.lead, children: t('params.none') }), version !== undefined && _jsx("div", { className: css.footRow, children: _jsx("button", { type: "button", className: css.link, onClick: () => { clearSaves(project.id); setRun(n => n + 1); toast(t('toast.savesCleared')); }, children: t('play.clearSaves') }) })] });
161
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 3D look previews in the panel: the element turntable, the proposal cards
3
+ * and the turn card's before/after. Every preview runs in the same sandbox as
4
+ * the play area, on the project's live version bundle (kit files and the
5
+ * project's own `src/looks.js` registrations included) with a preview entry
6
+ * swapped in — so the panel never carries a second copy of the builder, and
7
+ * what it shows is exactly what the game builds.
8
+ *
9
+ * The camera and the in-frame entry live in `preview-entry.ts`: a
10
+ * perspective orbit that faces the model (yaw 0 = its +x front).
11
+ *
12
+ * `spin` keeps a live, draggable turntable that follows look edits; `still`
13
+ * renders once, posts a PNG back and is replaced by that image (cached per
14
+ * look, so a scrolled-back card costs nothing). Stills render a couple at a
15
+ * time — a grid of element cards would otherwise open a WebGL context per
16
+ * card at once — and show their `placeholder` until the image arrives.
17
+ */
18
+ import { type ReactNode } from 'react';
19
+ import type { Look3D } from '../types.ts';
20
+ import { type ModelParts } from '../vfs.ts';
21
+ import type { GameApi } from './contract.ts';
22
+ /** Props of {@link Preview3D}. */
23
+ export interface Preview3DProps {
24
+ readonly api: GameApi;
25
+ readonly projectId: string;
26
+ /** The live version; previews need one to run (kit files and project registrations ride in it). */
27
+ readonly version: number | undefined;
28
+ readonly look: Look3D;
29
+ readonly mode: 'spin' | 'still';
30
+ readonly className?: string | undefined;
31
+ /** Shown while there is no live version yet. */
32
+ readonly emptyLabel: string;
33
+ /** Shown instead of `emptyLabel`, and under a still while it renders. */
34
+ readonly placeholder?: ReactNode;
35
+ /** A turntable reports a model's animation clips, and what it can be tuned by, once it has loaded. */
36
+ readonly onClips?: (names: readonly string[], parts: ModelParts | null) => void;
37
+ /** A turntable plays this clip (bump `n` to play the same one again). */
38
+ readonly clip?: {
39
+ readonly name: string;
40
+ readonly n: number;
41
+ } | null;
42
+ /** A turntable turns to this side (yaw in degrees: 0 front, 90 side, 180 back; bump `n` to repeat). */
43
+ readonly view?: {
44
+ readonly yaw: number;
45
+ readonly n: number;
46
+ } | null;
47
+ }
48
+ /** One 3D look preview. */
49
+ export declare function Preview3D({ api, projectId, version, look, mode, className, emptyLabel, placeholder, onClips, clip, view }: Preview3DProps): ReactNode;
50
+ //# sourceMappingURL=Preview3D.d.ts.map
@@ -0,0 +1,176 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * 3D look previews in the panel: the element turntable, the proposal cards
4
+ * and the turn card's before/after. Every preview runs in the same sandbox as
5
+ * the play area, on the project's live version bundle (kit files and the
6
+ * project's own `src/looks.js` registrations included) with a preview entry
7
+ * swapped in — so the panel never carries a second copy of the builder, and
8
+ * what it shows is exactly what the game builds.
9
+ *
10
+ * The camera and the in-frame entry live in `preview-entry.ts`: a
11
+ * perspective orbit that faces the model (yaw 0 = its +x front).
12
+ *
13
+ * `spin` keeps a live, draggable turntable that follows look edits; `still`
14
+ * renders once, posts a PNG back and is replaced by that image (cached per
15
+ * look, so a scrolled-back card costs nothing). Stills render a couple at a
16
+ * time — a grid of element cards would otherwise open a WebGL context per
17
+ * card at once — and show their `placeholder` until the image arrives.
18
+ */
19
+ import { useEffect, useMemo, useRef, useState } from 'react';
20
+ import { shellHtml } from "../vfs.js";
21
+ import { failureOf } from "./contract.js";
22
+ import { PREVIEW_HTML, PREVIEW_ID, PREVIEW_MAIN } from "./preview-entry.js";
23
+ import css from './GameTab.module.css';
24
+ /** Live version bundles per project, fetched once per version (three.js rides in them). */
25
+ const bundles = new Map();
26
+ function liveBundle(api, projectId, version) {
27
+ const key = `${projectId}:${version}`;
28
+ let pending = bundles.get(key);
29
+ if (pending === undefined) {
30
+ pending = api.bundle(projectId, 'current').then(r => (failureOf(r) === undefined && r.ok && r.value.ok ? r.value.bundle.files : null));
31
+ bundles.set(key, pending);
32
+ }
33
+ return pending;
34
+ }
35
+ /** The working copy of a glTF look's file (a model exported moments ago may not be in any version yet). */
36
+ async function withModel(api, projectId, look, files) {
37
+ if (files === null || look.kind !== 'gltf')
38
+ return files;
39
+ const r = await api.workingFile(projectId, look.src);
40
+ return failureOf(r) === undefined && r.ok && r.value.ok ? { ...files, [look.src]: r.value.file } : files;
41
+ }
42
+ /** Still images per look (and project version, whose style and registrations shape it). */
43
+ const stills = new Map();
44
+ /** Stills rendering at once; the rest wait their turn. */
45
+ const STILL_SLOTS = 2;
46
+ /** A still that never posts its image gives its slot back after this long. */
47
+ const STILL_TIMEOUT_MS = 15_000;
48
+ let stillsRunning = 0;
49
+ const stillQueue = [];
50
+ /** Wait for a render slot; the returned function gives it back (idempotent). */
51
+ function stillSlot() {
52
+ let granted = false;
53
+ let released = false;
54
+ let grant;
55
+ const ready = new Promise(resolve => { grant = () => { granted = true; stillsRunning++; resolve(); }; });
56
+ if (stillsRunning < STILL_SLOTS)
57
+ grant();
58
+ else
59
+ stillQueue.push(grant);
60
+ const release = () => {
61
+ if (released)
62
+ return;
63
+ released = true;
64
+ if (!granted) {
65
+ const at = stillQueue.indexOf(grant);
66
+ if (at >= 0)
67
+ stillQueue.splice(at, 1);
68
+ return;
69
+ }
70
+ stillsRunning--;
71
+ stillQueue.shift()?.();
72
+ };
73
+ return { ready, release };
74
+ }
75
+ /** One 3D look preview. */
76
+ export function Preview3D({ api, projectId, version, look, mode, className, emptyLabel, placeholder, onClips, clip, view }) {
77
+ const lookJson = JSON.stringify(look);
78
+ const stillKey = `${projectId}:${version}:${lookJson}`;
79
+ const [files, setFiles] = useState(null);
80
+ const [image, setImage] = useState(mode === 'still' ? stills.get(stillKey) ?? null : null);
81
+ const [visible, setVisible] = useState(mode === 'spin');
82
+ const box = useRef(null);
83
+ const frame = useRef(null);
84
+ const booted = useRef(false);
85
+ const shell = useMemo(() => shellHtml(), []);
86
+ // A glTF look's file: switching it reloads the frame (other look edits stream in live).
87
+ const modelSrc = look.kind === 'gltf' ? look.src : '';
88
+ useEffect(() => { booted.current = false; }, [modelSrc]);
89
+ // A still follows its key (a new live version or an edited look): start over from the cache or a fresh render.
90
+ useEffect(() => {
91
+ if (mode !== 'still')
92
+ return;
93
+ setImage(stills.get(stillKey) ?? null);
94
+ setFiles(null);
95
+ booted.current = false;
96
+ }, [mode, stillKey]);
97
+ // Stills wait until they scroll into view: a long chat history costs nothing until read.
98
+ useEffect(() => {
99
+ if (visible || box.current === null)
100
+ return;
101
+ const io = new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting))
102
+ setVisible(true); });
103
+ io.observe(box.current);
104
+ return () => { io.disconnect(); };
105
+ }, [visible]);
106
+ useEffect(() => {
107
+ if (version === undefined || !visible || image !== null)
108
+ return;
109
+ let live = true;
110
+ if (mode === 'spin') {
111
+ void liveBundle(api, projectId, version).then(f => withModel(api, projectId, look, f)).then(f => { if (live)
112
+ setFiles(f); });
113
+ return () => { live = false; };
114
+ }
115
+ // A still holds a render slot from loading until its image lands (or it times out).
116
+ const slot = stillSlot();
117
+ let timer;
118
+ void slot.ready.then(() => liveBundle(api, projectId, version)).then(f => withModel(api, projectId, look, f)).then(f => {
119
+ if (!live)
120
+ return;
121
+ if (f === null) {
122
+ slot.release();
123
+ return;
124
+ }
125
+ timer = setTimeout(slot.release, STILL_TIMEOUT_MS);
126
+ setFiles(f);
127
+ });
128
+ return () => { live = false; clearTimeout(timer); slot.release(); };
129
+ }, [api, projectId, version, visible, image, mode, modelSrc]); // eslint-disable-line react-hooks/exhaustive-deps -- look is read for its model file only (modelSrc)
130
+ useEffect(() => {
131
+ const onMessage = (event) => {
132
+ if (event.source !== frame.current?.contentWindow)
133
+ return;
134
+ const data = event.data;
135
+ if (data?.gamedev === 'preview' && mode === 'still') {
136
+ stills.set(stillKey, data.png);
137
+ setImage(data.png);
138
+ }
139
+ if (data?.gamedev === 'preview-clips' && mode === 'spin')
140
+ onClips?.(data.names, data.parts);
141
+ };
142
+ window.addEventListener('message', onMessage);
143
+ return () => { window.removeEventListener('message', onMessage); };
144
+ }, [mode, stillKey, onClips]);
145
+ useEffect(() => {
146
+ if (view == null || mode !== 'spin' || !booted.current)
147
+ return;
148
+ frame.current?.contentWindow?.postMessage({ gamedev: 'preview-view', yaw: view.yaw }, '*');
149
+ }, [view, mode]);
150
+ useEffect(() => {
151
+ if (clip == null || mode !== 'spin' || !booted.current)
152
+ return;
153
+ frame.current?.contentWindow?.postMessage({ gamedev: 'preview-play', name: clip.name }, '*');
154
+ }, [clip, mode]);
155
+ // A spinning preview follows look edits live (the same element message the play area gets).
156
+ useEffect(() => {
157
+ if (mode !== 'spin' || !booted.current)
158
+ return;
159
+ frame.current?.contentWindow?.postMessage({ gamedev: 'element', element: { id: PREVIEW_ID, name: PREVIEW_ID, type: 'preview', look } }, '*');
160
+ }, [lookJson, mode]); // eslint-disable-line react-hooks/exhaustive-deps -- lookJson stands for look
161
+ const boot = () => {
162
+ if (files === null || booted.current)
163
+ return;
164
+ const previewFiles = {
165
+ ...files,
166
+ 'index.html': { type: 'text/html', text: PREVIEW_HTML },
167
+ '_preview/main.js': { type: 'text/javascript', text: PREVIEW_MAIN },
168
+ '_preview/mode.json': { type: 'application/json', text: JSON.stringify({ mode }) },
169
+ [`elements/${PREVIEW_ID}.json`]: { type: 'application/json', text: JSON.stringify({ id: PREVIEW_ID, name: PREVIEW_ID, type: 'preview', look }) },
170
+ };
171
+ frame.current?.contentWindow?.postMessage({ gamedev: 'files', files: previewFiles, saves: {}, params: {} }, '*');
172
+ booted.current = true;
173
+ };
174
+ const waiting = placeholder ?? (version === undefined ? _jsx("span", { className: css.preview3dNote, children: emptyLabel }) : null);
175
+ return _jsx("div", { ref: box, className: [css.preview3d, className].filter(Boolean).join(' '), children: image !== null ? _jsx("img", { src: image, alt: "" }) : _jsxs(_Fragment, { children: [(mode === 'still' || version === undefined) && waiting, version !== undefined && files !== null && _jsx("iframe", { ref: frame, className: css.preview3dFrame, "data-mode": mode, title: "preview", sandbox: "allow-scripts", srcDoc: shell, onLoad: boot }, modelSrc)] }) });
176
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The project switcher: every game as a card — the last check's screen, the
3
+ * title, live version, art style, element count, last change and the pitch —
4
+ * with the session's own project marked. The first card starts a new game:
5
+ * creation goes through the agent (the light gate needs the user's pitch,
6
+ * element kinds and style), so it writes an opener into the composer.
7
+ */
8
+ import { type ReactNode } from 'react';
9
+ import { type ViewProps } from './contract.ts';
10
+ /** Props of the switcher. */
11
+ export interface ProjectSwitcherProps extends ViewProps {
12
+ readonly currentId: string | null;
13
+ /** Re-fetch trigger (state revision). */
14
+ readonly rev: number;
15
+ readonly onChoose: (projectId: string | null) => void;
16
+ readonly onNew: () => void;
17
+ }
18
+ /** The card grid. */
19
+ export declare function ProjectSwitcher({ t, api, sessionId, currentId, rev, onChoose, onNew, refresh, toast }: ProjectSwitcherProps): ReactNode;
20
+ //# sourceMappingURL=ProjectSwitcher.d.ts.map
@@ -0,0 +1,75 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The project switcher: every game as a card — the last check's screen, the
4
+ * title, live version, art style, element count, last change and the pitch —
5
+ * with the session's own project marked. The first card starts a new game:
6
+ * creation goes through the agent (the light gate needs the user's pitch,
7
+ * element kinds and style), so it writes an opener into the composer.
8
+ */
9
+ import { useEffect, useState } from 'react';
10
+ import { artStyleLabel } from "../project.js";
11
+ import { failureOf } from "./contract.js";
12
+ import css from './GameTab.module.css';
13
+ /** "3 分钟前" style relative time. */
14
+ function ago(t, iso) {
15
+ const ms = Date.now() - new Date(iso).getTime();
16
+ if (!Number.isFinite(ms))
17
+ return '';
18
+ const min = Math.round(ms / 60000);
19
+ if (min < 1)
20
+ return t('switcher.justNow');
21
+ if (min < 60)
22
+ return t('switcher.minutes', { count: min });
23
+ const h = Math.round(min / 60);
24
+ if (h < 24)
25
+ return t('switcher.hours', { count: h });
26
+ return t('switcher.days', { count: Math.round(h / 24) });
27
+ }
28
+ /** The last path segment (the project folder), the full path goes in the tooltip. */
29
+ function folderOf(root) {
30
+ return root.split(/[\\/]/).filter(Boolean).pop() ?? root;
31
+ }
32
+ /** The card grid. */
33
+ export function ProjectSwitcher({ t, api, sessionId, currentId, rev, onChoose, onNew, refresh, toast }) {
34
+ const [cards, setCards] = useState(null);
35
+ const [reload, setReload] = useState(0);
36
+ // The card being deleted: its inline confirmation, and the last failure to explain there.
37
+ const [confirming, setConfirming] = useState(null);
38
+ const [busy, setBusy] = useState(false);
39
+ useEffect(() => {
40
+ let live = true;
41
+ void api.projectCards().then(r => { if (live)
42
+ setCards(r.ok ? r.value : []); }).catch(() => { if (live)
43
+ setCards([]); });
44
+ return () => { live = false; };
45
+ }, [api, rev, reload]);
46
+ const remove = (card, mode) => {
47
+ setBusy(true);
48
+ void api.deleteProject(sessionId, card.id, mode).then(r => {
49
+ setBusy(false);
50
+ const failure = failureOf(r);
51
+ if (failure !== undefined) {
52
+ const why = r.ok && !r.value.ok ? r.value.reason ?? '' : '';
53
+ const reason = failure === 'trash-failed' ? t('switcher.deleteTrashFailed', { reason: why })
54
+ : failure === 'not-a-project' ? t('switcher.deleteNotProject')
55
+ : failure === 'is-workspace' ? t('switcher.deleteIsWorkspace')
56
+ : failure === 'denied' ? t('switcher.deleteDenied') : failure;
57
+ setConfirming({ id: card.id, error: reason });
58
+ return;
59
+ }
60
+ setConfirming(null);
61
+ toast(mode === 'trash' ? t('switcher.deletedTrash', { title: card.title }) : t('switcher.deletedForget', { title: card.title }));
62
+ setReload(n => n + 1);
63
+ refresh();
64
+ });
65
+ };
66
+ return _jsxs("div", { className: css.projectGrid, children: [_jsxs("button", { type: "button", className: css.newCard, onClick: onNew, children: [_jsx("span", { className: css.newPlus, "aria-hidden": "true", children: "\uFF0B" }), _jsx("strong", { children: t('switcher.new') }), _jsx("span", { className: css.metaLine, children: t('switcher.newHint') })] }), cards === null && _jsx("div", { className: css.projectCard, "data-loading": "true", children: _jsx("span", { className: css.metaLine, children: t('loading') }) }), cards?.map(card => {
67
+ const current = card.id === currentId;
68
+ if (confirming?.id === card.id) {
69
+ return _jsx("div", { className: css.projectCard, "data-confirm": "true", role: "group", "aria-label": t('switcher.deleteTitle', { title: card.title }), children: _jsxs("span", { className: css.projectBody, children: [_jsx("strong", { className: css.projectTitle, children: t('switcher.deleteTitle', { title: card.title }) }), _jsx("span", { className: css.metaLine, children: t('switcher.deleteBody', { folder: card.root }) }), confirming.error !== undefined && _jsx("span", { className: css.deleteError, children: confirming.error }), _jsxs("span", { className: css.deleteActions, children: [_jsx("button", { type: "button", className: css.danger, disabled: busy, onClick: () => { remove(card, 'trash'); }, children: t('switcher.deleteTrash') }), _jsx("button", { type: "button", className: css.ghost, disabled: busy, onClick: () => { remove(card, 'forget'); }, children: t('switcher.deleteForget') }), _jsx("button", { type: "button", className: css.link, disabled: busy, onClick: () => { setConfirming(null); }, children: t('switcher.deleteCancel') })] })] }) }, card.id);
70
+ }
71
+ return _jsxs("div", { className: css.projectCardWrap, children: [_jsxs("button", { type: "button", className: css.projectCard, "aria-current": current ? 'true' : undefined, title: card.root, onClick: () => { onChoose(card.id); }, children: [_jsxs("span", { className: css.projectThumb, children: [card.thumb !== undefined
72
+ ? _jsx("img", { src: `data:image/webp;base64,${card.thumb}`, alt: "" })
73
+ : _jsx("span", { className: css.projectInitial, children: card.title.slice(0, 1) }), current && _jsx("span", { className: css.projectHere, children: t('switcher.here') })] }), _jsxs("span", { className: css.projectBody, children: [_jsx("strong", { className: css.projectTitle, children: card.title }), _jsxs("span", { className: css.projectChips, children: [_jsx("span", { className: css.headChip, "data-tone": card.version === null ? 'muted' : 'ok', children: card.version === null ? t('switcher.noVersion') : `v${card.version}` }), card.artStyle !== '' && _jsx("span", { className: css.headChip, children: artStyleLabel(card.artStyle) }), _jsx("span", { className: css.headChip, children: t('switcher.elements', { count: card.elements }) })] }), card.pitch !== '' && _jsx("span", { className: css.projectPitch, children: card.pitch }), _jsxs("span", { className: css.metaLine, children: [ago(t, card.updatedAt), " \u00B7 ", folderOf(card.root)] })] })] }), _jsx("button", { type: "button", className: css.projectDelete, "aria-label": t('switcher.delete', { title: card.title }), title: t('switcher.delete', { title: card.title }), onClick: () => { setConfirming({ id: card.id }); }, children: "\u00D7" })] }, card.id);
74
+ })] });
75
+ }