spawnpoint-studio 0.1.0__py3-none-any.whl
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.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Spawn Point Studio — cockpit page. Plain JS, no build step.
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const state = { projects: [], current: null, detail: null, status: null, selected: null, running: 0 };
|
|
5
|
+
const $ = (sel) => document.querySelector(sel);
|
|
6
|
+
|
|
7
|
+
// Builds DOM without innerHTML (names and text may come from an LLM).
|
|
8
|
+
function h(tag, attrs = {}, ...children) {
|
|
9
|
+
const el = document.createElement(tag);
|
|
10
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
11
|
+
if (v === null || v === undefined || v === false) continue;
|
|
12
|
+
if (k === "class") el.className = v;
|
|
13
|
+
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
|
14
|
+
else if (k === "text") el.textContent = v;
|
|
15
|
+
else el.setAttribute(k, v === true ? "" : v);
|
|
16
|
+
}
|
|
17
|
+
for (const c of children.flat()) {
|
|
18
|
+
if (c === null || c === undefined || c === false) continue;
|
|
19
|
+
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
|
20
|
+
}
|
|
21
|
+
return el;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function api(path, body) {
|
|
25
|
+
const opts = body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) };
|
|
26
|
+
const res = await fetch(path, opts);
|
|
27
|
+
const data = await res.json().catch(() => ({}));
|
|
28
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function log(text, cls = "") {
|
|
33
|
+
const box = $("#log");
|
|
34
|
+
box.append(h("div", { class: cls, text: `${new Date().toLocaleTimeString()} ${text}` }));
|
|
35
|
+
while (box.childElementCount > 400) box.firstChild.remove();
|
|
36
|
+
box.scrollTop = box.scrollHeight;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function submitJob(promise, label) {
|
|
40
|
+
try {
|
|
41
|
+
const job = await promise;
|
|
42
|
+
log(`#${job.id} ${label} — queued`, "start");
|
|
43
|
+
} catch (e) {
|
|
44
|
+
log(`${label}: ${e.message}`, "error");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- status ---------------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
async function refreshStatus() {
|
|
51
|
+
try {
|
|
52
|
+
state.status = await api("/api/status");
|
|
53
|
+
} catch { return; }
|
|
54
|
+
const s = state.status;
|
|
55
|
+
const llm = $("#pill-llm");
|
|
56
|
+
llm.textContent = s.llm ? `LLM ${s.model || ""}` : "LLM offline";
|
|
57
|
+
llm.className = `pill ${s.llm ? "ok" : "down"}`;
|
|
58
|
+
llm.title = s.llm ? s.base_url : `No LLM at ${s.base_url}. Start one with scripts/llm-up.sh — parameter edits and playtests still work.`;
|
|
59
|
+
const gd = $("#pill-godot");
|
|
60
|
+
gd.textContent = s.godot ? "Godot ready" : "Godot missing";
|
|
61
|
+
gd.className = `pill ${s.godot ? "ok" : "down"}`;
|
|
62
|
+
document.querySelectorAll("button.llm").forEach((b) => { b.disabled = !s.llm; b.title = s.llm ? "" : llm.title; });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setBusy(delta) {
|
|
66
|
+
state.running = Math.max(0, state.running + delta);
|
|
67
|
+
const p = $("#pill-job");
|
|
68
|
+
p.textContent = state.running ? `working… (${state.running})` : "idle";
|
|
69
|
+
p.className = `pill ${state.running ? "busy" : "idle"}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- projects -------------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async function loadProjects() {
|
|
75
|
+
state.projects = await api("/api/projects");
|
|
76
|
+
const ul = $("#project-list");
|
|
77
|
+
ul.replaceChildren(...state.projects.map((p) =>
|
|
78
|
+
h("li", { class: p.name === state.current ? "active" : "", onclick: () => openProject(p.name), "data-name": p.name },
|
|
79
|
+
h("span", { text: p.title }),
|
|
80
|
+
h("span", { class: "badge", text: `${p.genre}${p.studio ? "" : " · graph"}` }))));
|
|
81
|
+
if (!state.projects.length) ul.append(h("li", { class: "muted", text: "No games yet." }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function openProject(name, { keepGame = false } = {}) {
|
|
85
|
+
const changed = state.current !== name;
|
|
86
|
+
state.current = name;
|
|
87
|
+
document.querySelectorAll("#project-list li").forEach((li) => li.classList.toggle("active", li.dataset.name === name));
|
|
88
|
+
state.detail = await api(`/api/projects/${encodeURIComponent(name)}`);
|
|
89
|
+
const d = state.detail;
|
|
90
|
+
$("#stage-empty").hidden = true;
|
|
91
|
+
$("#stage-noweb").hidden = d.web;
|
|
92
|
+
const frame = $("#game");
|
|
93
|
+
if (d.web && (changed || !keepGame || !frame.src)) frame.src = `/game/${encodeURIComponent(name)}/`;
|
|
94
|
+
if (!d.web) frame.removeAttribute("src");
|
|
95
|
+
if (changed) clearSelection();
|
|
96
|
+
renderDesks();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- desks ----------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function renderDesks() {
|
|
102
|
+
renderDesigner();
|
|
103
|
+
renderArtist();
|
|
104
|
+
renderDeveloper();
|
|
105
|
+
renderQA();
|
|
106
|
+
if (state.selected) renderSelection();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function chips(items) {
|
|
110
|
+
return h("div", { class: "chips" }, items.map((t) => h("span", { class: "chip", text: t })));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderDesigner() {
|
|
114
|
+
const d = state.detail;
|
|
115
|
+
const box = $("#tab-designer");
|
|
116
|
+
if (!d) return box.replaceChildren();
|
|
117
|
+
const rows = [];
|
|
118
|
+
const kv = (k, v) => rows.push(h("dt", { text: k }), h("dd", {}, v));
|
|
119
|
+
if (d.brief) {
|
|
120
|
+
const b = d.brief;
|
|
121
|
+
kv("Title", b.title);
|
|
122
|
+
kv("Genre", `${b.genre} · ${b.palette} palette`);
|
|
123
|
+
kv("Goal", b.goal + (b.goal === "score_target" ? ` (${b.score_target})` : b.goal === "survive" ? ` (${b.survive_seconds}s)` : ""));
|
|
124
|
+
kv("Player", chips([b.player.character, ...b.player.abilities, `jump ${b.player.jump_tiles} tiles`, `${b.player.speed}`, `${b.player.health} hp`]));
|
|
125
|
+
kv("Enemies", chips(b.enemies.map((e) => `${e.count}× ${e.name} (${e.archetype})`)));
|
|
126
|
+
kv("Pickups", `${b.collectible.count}× ${b.collectible.name}` + (b.health_pickups ? ` · ${b.health_pickups} hearts` : ""));
|
|
127
|
+
kv("Level", `${b.length} · difficulty ${b.difficulty} · ${b.hazards.join(", ") || "no hazards"}`);
|
|
128
|
+
kv("Feel", `${b.feel} · juice ${b.juice}`);
|
|
129
|
+
kv("Intro", b.intro);
|
|
130
|
+
} else {
|
|
131
|
+
kv("Title", d.title);
|
|
132
|
+
kv("Genre", d.genre);
|
|
133
|
+
kv("Entities", chips(Object.keys(d.entities)));
|
|
134
|
+
}
|
|
135
|
+
const parts = [h("dl", { class: "kv" }, rows), h("h2", { text: "Rules" }),
|
|
136
|
+
h("ul", { class: "changes" }, d.rules.map((r) => h("li", { text: `on ${r.on}${r.if ? ` if ${r.if}` : ""} → ${[].concat(r.do).join(", ")}` })))];
|
|
137
|
+
if (d.history.length) parts.push(h("h2", { text: "Tweaks" }), h("ul", { class: "changes" }, d.history.map((x) => h("li", {}, h("b", { text: x.request }), h("div", { class: "muted", text: x.changes.join(" · ") || "no change" })))));
|
|
138
|
+
if (d.overrides.length) parts.push(h("h2", { text: `Studio edits (${d.overrides.length})` }), h("ul", { class: "changes" }, d.overrides.map((o) => h("li", { class: "muted", text: describePatch(o) }))));
|
|
139
|
+
if (d.tuning.length) parts.push(h("h2", { text: "Playtest tuning" }), h("ul", { class: "changes" }, d.tuning.map((t) => h("li", { class: "muted", text: `${t.entity}.${t.component}.${t.param} = ${t.value}` }))));
|
|
140
|
+
box.replaceChildren(...parts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function describePatch(p) {
|
|
144
|
+
const out = [];
|
|
145
|
+
(p.set_params || []).forEach((s) => out.push(`${s.entity}.${s.component}.${s.param} = ${s.value}`));
|
|
146
|
+
(p.add_components || []).forEach((a) => out.push(`+${a.component} on ${a.entity}`));
|
|
147
|
+
Object.keys(p.add_entities || {}).forEach((n) => out.push(`+entity ${n}`));
|
|
148
|
+
(p.add_rules || []).forEach((r) => out.push(`+rule on ${r.on}`));
|
|
149
|
+
return out.join(" · ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderArtist() {
|
|
153
|
+
const d = state.detail;
|
|
154
|
+
const box = $("#tab-artist");
|
|
155
|
+
if (!d) return box.replaceChildren();
|
|
156
|
+
const cards = Object.entries(d.entities).map(([name, e]) =>
|
|
157
|
+
h("div", { class: "sprite-card", onclick: () => select(name) },
|
|
158
|
+
h("div", { class: "thumb" }, e.sprite
|
|
159
|
+
? h("img", { src: `/files/${encodeURIComponent(d.name)}/sprites/${encodeURIComponent(name)}.png?t=${Date.now()}`, alt: name })
|
|
160
|
+
: h("span", { class: "muted", text: "shape" })),
|
|
161
|
+
h("div", { text: name })));
|
|
162
|
+
box.replaceChildren(h("p", { class: "muted", text: "Sprite sheets, one row per animation. Click one to select the entity." }), h("div", { class: "gallery" }, cards));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function paramInput(entity, comp, p) {
|
|
166
|
+
const send = (value) => submitJob(api(`/api/projects/${encodeURIComponent(state.current)}/params`, { entity, component: comp, param: p.name, value }), `${entity}.${comp}.${p.name} = ${value}`);
|
|
167
|
+
let input;
|
|
168
|
+
if (p.type === "bool") {
|
|
169
|
+
input = h("input", { type: "checkbox", onchange: (e) => send(e.target.checked) });
|
|
170
|
+
input.checked = !!p.value;
|
|
171
|
+
} else if (p.choices) {
|
|
172
|
+
input = h("select", { onchange: (e) => send(e.target.value) }, p.choices.map((c) => h("option", { value: c, text: c, selected: c === p.value })));
|
|
173
|
+
} else if (p.type === "int" || p.type === "float") {
|
|
174
|
+
const step = p.type === "int" ? 1 : (Math.abs(p.max ?? 1) > 50 ? 1 : 0.01);
|
|
175
|
+
input = h("input", { type: "number", value: p.value, min: p.min ?? null, max: p.max ?? null, step,
|
|
176
|
+
onchange: (e) => { const v = p.type === "int" ? parseInt(e.target.value, 10) : parseFloat(e.target.value); if (!Number.isNaN(v)) send(v); } });
|
|
177
|
+
} else {
|
|
178
|
+
input = h("input", { type: "text", value: p.value ?? "", onchange: (e) => send(e.target.value) });
|
|
179
|
+
}
|
|
180
|
+
return h("div", { class: `param ${p.set ? "set" : ""}`, title: p.doc || "" }, h("label", { text: p.name }), input);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderDeveloper() {
|
|
184
|
+
const d = state.detail;
|
|
185
|
+
const box = $("#tab-developer");
|
|
186
|
+
if (!d) return box.replaceChildren();
|
|
187
|
+
const items = Object.entries(d.entities).map(([name, e]) => {
|
|
188
|
+
const det = h("details", { class: `entity ${state.selected === name ? "selected" : ""}`, "data-entity": name, open: state.selected === name },
|
|
189
|
+
h("summary", {}, h("span", { text: name }), h("span", { class: "badge", text: `${e.body}${e.team ? " · " + e.team : ""}` })),
|
|
190
|
+
Object.entries(e.components).map(([cname, c]) => h("div", { class: "component" },
|
|
191
|
+
h("h4", { text: cname, title: c.doc }),
|
|
192
|
+
c.params.length ? c.params.map((p) => paramInput(name, cname, p)) : h("div", { class: "muted", text: "no parameters" }))));
|
|
193
|
+
return det;
|
|
194
|
+
});
|
|
195
|
+
box.replaceChildren(h("p", { class: "muted", text: "Edit any parameter: the game rebuilds and reloads (no LLM needed). Bold = set by the game, grey = default." }), ...items);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderQA() {
|
|
199
|
+
const d = state.detail;
|
|
200
|
+
const box = $("#tab-qa");
|
|
201
|
+
if (!d) return box.replaceChildren();
|
|
202
|
+
const run = (tune) => submitJob(api(`/api/projects/${encodeURIComponent(d.name)}/playtest`, { tune }), tune ? "playtest + tune" : "playtest");
|
|
203
|
+
const parts = [h("div", { class: "row" },
|
|
204
|
+
h("button", { onclick: () => run(false), text: "Run playtest" }),
|
|
205
|
+
h("button", { class: "primary", onclick: () => run(true), text: "Run + tune" }))];
|
|
206
|
+
const pt = d.playtest;
|
|
207
|
+
if (!pt) {
|
|
208
|
+
parts.push(h("p", { class: "muted", text: "Bots (novice / average / expert) play the game headless, report where and why they die, and can tune parameters within safe bounds." }));
|
|
209
|
+
} else {
|
|
210
|
+
const prof = pt.final.profiles;
|
|
211
|
+
parts.push(h("table", { class: "stats" },
|
|
212
|
+
h("tr", {}, ["profile", "won", "deaths", "top causes", "pickups"].map((t) => h("th", { text: t }))),
|
|
213
|
+
Object.values(prof).map((p) => h("tr", {},
|
|
214
|
+
h("td", { text: p.profile }), h("td", { text: `${p.wins}/${p.runs}` }), h("td", { text: p.deaths }),
|
|
215
|
+
h("td", { text: Object.entries(p.causes).slice(0, 3).map(([k, v]) => `${k} ${v}`).join(", ") || "—" }),
|
|
216
|
+
h("td", { text: `${Math.round(p.pickup_rate * 100)}%` })))));
|
|
217
|
+
parts.push(h("p", { class: "muted", text: `score ${pt.score.baseline} → ${pt.score.final} (lower is better) · difficulty ${pt.difficulty} · targets ${JSON.stringify(pt.targets)}` }));
|
|
218
|
+
if (pt.changes.length) parts.push(h("h2", { text: "Tuning applied" }), h("ul", { class: "changes" }, pt.changes.map((c) => h("li", {}, h("b", { text: `${c.entity}.${c.component}.${c.param} = ${c.value}` }), h("div", { class: "muted", text: c.reason })))));
|
|
219
|
+
if (pt.final.level_issues.length) parts.push(h("h2", { text: "Level issues" }), h("ul", { class: "issues" }, pt.final.level_issues.map((i) => h("li", { text: i }))));
|
|
220
|
+
parts.push(h("img", { class: "heatmap", src: `/files/${encodeURIComponent(d.name)}/playtest/heatmap.png?t=${Date.now()}`, alt: "death heatmap" }));
|
|
221
|
+
}
|
|
222
|
+
box.replaceChildren(...parts);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- selection (click-to-edit) --------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function select(entity) {
|
|
228
|
+
if (!state.detail || !state.detail.entities[entity]) return;
|
|
229
|
+
state.selected = entity;
|
|
230
|
+
renderSelection();
|
|
231
|
+
renderDeveloper();
|
|
232
|
+
showTab("developer");
|
|
233
|
+
const el = document.querySelector(`details[data-entity="${CSS.escape(entity)}"]`);
|
|
234
|
+
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
235
|
+
$("#ask-input").focus();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderSelection() {
|
|
239
|
+
const e = state.detail && state.detail.entities[state.selected];
|
|
240
|
+
if (!e) return clearSelection();
|
|
241
|
+
$("#selection").hidden = false;
|
|
242
|
+
$("#sel-name").textContent = state.selected;
|
|
243
|
+
$("#sel-summary").textContent = Object.keys(e.components).join(" · ") || "no components";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clearSelection() {
|
|
247
|
+
state.selected = null;
|
|
248
|
+
$("#selection").hidden = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
window.addEventListener("message", (ev) => {
|
|
252
|
+
const data = ev.data;
|
|
253
|
+
if (data && data.spawnpoint === "select" && typeof data.entity === "string") {
|
|
254
|
+
log(`selected ${data.entity} (${data.node}) in the game`);
|
|
255
|
+
select(data.entity);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// --- tabs & forms ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
function showTab(name) {
|
|
262
|
+
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === name));
|
|
263
|
+
document.querySelectorAll(".desk").forEach((d) => { d.hidden = d.id !== `tab-${name}`; });
|
|
264
|
+
}
|
|
265
|
+
document.querySelectorAll(".tab").forEach((t) => t.addEventListener("click", () => showTab(t.dataset.tab)));
|
|
266
|
+
|
|
267
|
+
$("#new-game").addEventListener("submit", (ev) => {
|
|
268
|
+
ev.preventDefault();
|
|
269
|
+
const prompt = $("#prompt").value.trim();
|
|
270
|
+
if (!prompt) return;
|
|
271
|
+
submitJob(api("/api/create", { prompt, playtest: $("#opt-playtest").checked, creative: $("#opt-creative").checked }), `create “${prompt}”`);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
$("#tweak").addEventListener("submit", (ev) => {
|
|
275
|
+
ev.preventDefault();
|
|
276
|
+
const request = $("#tweak-input").value.trim();
|
|
277
|
+
if (!request || !state.current) return;
|
|
278
|
+
submitJob(api(`/api/projects/${encodeURIComponent(state.current)}/tweak`, { request }), `tweak “${request}”`);
|
|
279
|
+
$("#tweak-input").value = "";
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
$("#ask").addEventListener("submit", (ev) => {
|
|
283
|
+
ev.preventDefault();
|
|
284
|
+
const request = $("#ask-input").value.trim();
|
|
285
|
+
if (!request || !state.current || !state.selected) return;
|
|
286
|
+
submitJob(api(`/api/projects/${encodeURIComponent(state.current)}/entity`, { entity: state.selected, request }), `${state.selected}: “${request}”`);
|
|
287
|
+
$("#ask-input").value = "";
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
$("#sel-close").addEventListener("click", () => { clearSelection(); renderDeveloper(); });
|
|
291
|
+
$("#btn-export").addEventListener("click", () => state.current && submitJob(api(`/api/projects/${encodeURIComponent(state.current)}/export`, {}), "web build"));
|
|
292
|
+
|
|
293
|
+
// --- live events ----------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function connectEvents() {
|
|
296
|
+
const es = new EventSource("/api/events");
|
|
297
|
+
es.onmessage = async (msg) => {
|
|
298
|
+
const ev = JSON.parse(msg.data);
|
|
299
|
+
const job = ev.job;
|
|
300
|
+
switch (ev.type) {
|
|
301
|
+
case "started": setBusy(+1); log(`#${job.id} ${job.kind}${job.project ? " " + job.project : ""} — running`, "start"); break;
|
|
302
|
+
case "log": log(`#${ev.job} ${ev.message}`); break;
|
|
303
|
+
case "reload":
|
|
304
|
+
await loadProjects();
|
|
305
|
+
if (!state.current) await openProject(ev.project);
|
|
306
|
+
else if (ev.project === state.current) await openProject(ev.project, { keepGame: true });
|
|
307
|
+
break;
|
|
308
|
+
case "done":
|
|
309
|
+
setBusy(-1);
|
|
310
|
+
log(`#${job.id} ${job.kind} — done`, "done");
|
|
311
|
+
await loadProjects();
|
|
312
|
+
if (job.kind === "create" && job.result && job.result.project) await openProject(job.result.project);
|
|
313
|
+
else if (job.project && job.project === state.current) await openProject(job.project, { keepGame: true });
|
|
314
|
+
break;
|
|
315
|
+
case "error": setBusy(-1); log(`#${job.id} ${job.kind} — failed: ${job.error}`, "error"); break;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
es.onerror = () => { /* EventSource reconnects by itself */ };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
(async function main() {
|
|
322
|
+
connectEvents();
|
|
323
|
+
await refreshStatus();
|
|
324
|
+
setInterval(refreshStatus, 30000);
|
|
325
|
+
await loadProjects();
|
|
326
|
+
if (state.projects.length) await openProject(state.projects[0].name);
|
|
327
|
+
})();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Spawn Point Studio</title>
|
|
7
|
+
<link rel="stylesheet" href="/static/app.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="topbar">
|
|
11
|
+
<div class="brand"><span class="logo">▲</span> Spawn Point <b>Studio</b></div>
|
|
12
|
+
<div class="pills">
|
|
13
|
+
<span id="pill-llm" class="pill">LLM …</span>
|
|
14
|
+
<span id="pill-godot" class="pill">Godot …</span>
|
|
15
|
+
<span id="pill-job" class="pill idle">idle</span>
|
|
16
|
+
</div>
|
|
17
|
+
</header>
|
|
18
|
+
|
|
19
|
+
<main class="floor">
|
|
20
|
+
<aside class="panel" id="projects">
|
|
21
|
+
<form id="new-game" class="card">
|
|
22
|
+
<label for="prompt">New game</label>
|
|
23
|
+
<textarea id="prompt" rows="3" placeholder="a ninja platformer where you collect stars…"></textarea>
|
|
24
|
+
<div class="row">
|
|
25
|
+
<label class="check"><input type="checkbox" id="opt-playtest"> playtest & tune</label>
|
|
26
|
+
<label class="check"><input type="checkbox" id="opt-creative"> creative pass</label>
|
|
27
|
+
</div>
|
|
28
|
+
<button type="submit" class="primary llm">Create</button>
|
|
29
|
+
</form>
|
|
30
|
+
<h2>Games</h2>
|
|
31
|
+
<ul id="project-list" class="list"></ul>
|
|
32
|
+
</aside>
|
|
33
|
+
|
|
34
|
+
<section class="panel" id="arcade">
|
|
35
|
+
<div class="stage">
|
|
36
|
+
<iframe id="game" title="game" allow="autoplay; fullscreen"></iframe>
|
|
37
|
+
<div id="stage-empty" class="stage-empty">
|
|
38
|
+
<p>Pick a game on the left, or describe a new one.</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div id="stage-noweb" class="stage-empty" hidden>
|
|
41
|
+
<p>This game has no web build yet.</p>
|
|
42
|
+
<button id="btn-export" class="primary">Build web version</button>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<p class="hint">Click the game to play · <b>right-click</b> an enemy or item to select it · R restarts</p>
|
|
46
|
+
<form id="tweak" class="bar">
|
|
47
|
+
<input id="tweak-input" placeholder="Change the game: “jump 20% higher, add a dash, night colors”" autocomplete="off">
|
|
48
|
+
<button type="submit" class="primary llm">Tweak</button>
|
|
49
|
+
</form>
|
|
50
|
+
<div id="log" class="log" aria-live="polite"></div>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<aside class="panel" id="desks">
|
|
54
|
+
<section id="selection" class="card selection" hidden>
|
|
55
|
+
<div class="row between">
|
|
56
|
+
<h3>Selected: <span id="sel-name"></span></h3>
|
|
57
|
+
<button id="sel-close" class="ghost" title="clear selection">✕</button>
|
|
58
|
+
</div>
|
|
59
|
+
<p id="sel-summary" class="muted"></p>
|
|
60
|
+
<form id="ask" class="bar">
|
|
61
|
+
<input id="ask-input" placeholder="“double its health and make it shoot fireballs”" autocomplete="off">
|
|
62
|
+
<button type="submit" class="primary llm">Ask</button>
|
|
63
|
+
</form>
|
|
64
|
+
</section>
|
|
65
|
+
<nav class="tabs" role="tablist">
|
|
66
|
+
<button class="tab active" data-tab="designer">Designer</button>
|
|
67
|
+
<button class="tab" data-tab="artist">Artist</button>
|
|
68
|
+
<button class="tab" data-tab="developer">Developer</button>
|
|
69
|
+
<button class="tab" data-tab="qa">QA</button>
|
|
70
|
+
</nav>
|
|
71
|
+
<div class="desk" id="tab-designer"></div>
|
|
72
|
+
<div class="desk" id="tab-artist" hidden></div>
|
|
73
|
+
<div class="desk" id="tab-developer" hidden></div>
|
|
74
|
+
<div class="desk" id="tab-qa" hidden></div>
|
|
75
|
+
</aside>
|
|
76
|
+
</main>
|
|
77
|
+
<script src="/static/app.js"></script>
|
|
78
|
+
</body>
|
|
79
|
+
</html>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Structured diagnostics — the feedback channel for humans and for the LLM orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Iterable, Literal
|
|
8
|
+
|
|
9
|
+
Severity = Literal["error", "warning"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Diagnostic:
|
|
14
|
+
severity: Severity
|
|
15
|
+
code: str
|
|
16
|
+
path: str
|
|
17
|
+
message: str
|
|
18
|
+
hint: str | None = None
|
|
19
|
+
|
|
20
|
+
def to_dict(self) -> dict:
|
|
21
|
+
d = asdict(self)
|
|
22
|
+
if d["hint"] is None:
|
|
23
|
+
del d["hint"]
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
def __str__(self) -> str:
|
|
27
|
+
s = f"{self.severity}[{self.code}] {self.path}: {self.message}"
|
|
28
|
+
if self.hint:
|
|
29
|
+
s += f" (hint: {self.hint})"
|
|
30
|
+
return s
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Diagnostics:
|
|
35
|
+
items: list[Diagnostic] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
def error(self, code: str, path: str, message: str, hint: str | None = None) -> None:
|
|
38
|
+
self.items.append(Diagnostic("error", code, path, message, hint))
|
|
39
|
+
|
|
40
|
+
def warning(self, code: str, path: str, message: str, hint: str | None = None) -> None:
|
|
41
|
+
self.items.append(Diagnostic("warning", code, path, message, hint))
|
|
42
|
+
|
|
43
|
+
def extend(self, other: Iterable[Diagnostic]) -> None:
|
|
44
|
+
self.items.extend(other)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def errors(self) -> list[Diagnostic]:
|
|
48
|
+
return [d for d in self.items if d.severity == "error"]
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def warnings(self) -> list[Diagnostic]:
|
|
52
|
+
return [d for d in self.items if d.severity == "warning"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ok(self) -> bool:
|
|
56
|
+
return not self.errors
|
|
57
|
+
|
|
58
|
+
def __iter__(self):
|
|
59
|
+
return iter(self.items)
|
|
60
|
+
|
|
61
|
+
def __len__(self) -> int:
|
|
62
|
+
return len(self.items)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def did_you_mean(word: str, options: Iterable[str]) -> str | None:
|
|
66
|
+
"""Return a 'Did you mean ...?' hint, or a list of valid options when nothing is close."""
|
|
67
|
+
opts = sorted(set(options))
|
|
68
|
+
if not opts:
|
|
69
|
+
return None
|
|
70
|
+
close = difflib.get_close_matches(word, opts, n=1, cutoff=0.6)
|
|
71
|
+
if not close:
|
|
72
|
+
lowered = {o.lower(): o for o in opts}
|
|
73
|
+
if word.lower() in lowered:
|
|
74
|
+
close = [lowered[word.lower()]]
|
|
75
|
+
if close:
|
|
76
|
+
return f"Did you mean '{close[0]}'?"
|
|
77
|
+
if len(opts) <= 24:
|
|
78
|
+
return "Valid options: " + ", ".join(opts)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def join_path(*parts: object) -> str:
|
|
83
|
+
out = ""
|
|
84
|
+
for p in parts:
|
|
85
|
+
if isinstance(p, int):
|
|
86
|
+
out += f"[{p}]"
|
|
87
|
+
elif out:
|
|
88
|
+
out += f".{p}"
|
|
89
|
+
else:
|
|
90
|
+
out = str(p)
|
|
91
|
+
return out
|