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,148 @@
|
|
|
1
|
+
"""Aggregates bot telemetry into per-profile metrics, a Markdown report and a death heatmap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from PIL import Image, ImageDraw
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ProfileStats:
|
|
14
|
+
profile: str
|
|
15
|
+
runs: int
|
|
16
|
+
wins: int
|
|
17
|
+
mean_time: float
|
|
18
|
+
deaths: int
|
|
19
|
+
causes: dict[str, int]
|
|
20
|
+
falls_after_jump: int
|
|
21
|
+
jumps_intended: int
|
|
22
|
+
jumps_missed: int
|
|
23
|
+
pickup_rate: float
|
|
24
|
+
stuck: int
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def win_rate(self) -> float:
|
|
28
|
+
return self.wins / self.runs if self.runs else 0.0
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def missed_rate(self) -> float:
|
|
32
|
+
return self.jumps_missed / self.jumps_intended if self.jumps_intended else 0.0
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
d = dict(self.__dict__)
|
|
36
|
+
d.update(win_rate=round(self.win_rate, 3), missed_rate=round(self.missed_rate, 3))
|
|
37
|
+
return d
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Report:
|
|
42
|
+
profiles: dict[str, ProfileStats]
|
|
43
|
+
runs: list[dict]
|
|
44
|
+
level_issues: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def get(self, profile: str) -> ProfileStats | None:
|
|
47
|
+
return self.profiles.get(profile)
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict:
|
|
50
|
+
return {"profiles": {k: v.to_dict() for k, v in self.profiles.items()},
|
|
51
|
+
"level_issues": self.level_issues, "runs": self.runs}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def aggregate(runs: list[dict], goal_entities: set[str] | None = None) -> Report:
|
|
55
|
+
"""`goal_entities`: entity types the player must reach (pickups, exits); an unreachable one
|
|
56
|
+
is a level problem. Enemies the bot could not path to are not."""
|
|
57
|
+
by: dict[str, list[dict]] = {}
|
|
58
|
+
for r in runs:
|
|
59
|
+
by.setdefault(r["profile"], []).append(r)
|
|
60
|
+
stats = {}
|
|
61
|
+
for prof, rs in by.items():
|
|
62
|
+
deaths = [d for r in rs for d in r["deaths"]]
|
|
63
|
+
won = [r for r in rs if r["outcome"] == "won"]
|
|
64
|
+
total = sum(r["pickups"]["total"] for r in rs)
|
|
65
|
+
stats[prof] = ProfileStats(
|
|
66
|
+
profile=prof, runs=len(rs), wins=len(won),
|
|
67
|
+
mean_time=round(sum(r["time"] for r in won) / len(won), 2) if won else 0.0,
|
|
68
|
+
deaths=len(deaths), causes=dict(Counter(d["cause"] for d in deaths).most_common()),
|
|
69
|
+
falls_after_jump=sum(1 for d in deaths if d["cause"] == "fall" and d.get("after_jump")),
|
|
70
|
+
jumps_intended=sum(r["jumps"]["intended"] for r in rs),
|
|
71
|
+
jumps_missed=sum(len(r["jumps"]["missed"]) for r in rs),
|
|
72
|
+
pickup_rate=round(sum(r["pickups"]["collected"] for r in rs) / total, 3) if total else 1.0,
|
|
73
|
+
stuck=sum(len(r["stuck"]) for r in rs),
|
|
74
|
+
)
|
|
75
|
+
return Report(stats, runs, _level_issues(runs, stats, goal_entities))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _level_issues(runs: list[dict], stats: dict[str, ProfileStats], goal_entities: set[str] | None) -> list[str]:
|
|
79
|
+
issues = []
|
|
80
|
+
no_path = Counter((u["entity"], u["x"], u["y"]) for r in runs for u in r.get("unreachable", [])
|
|
81
|
+
if u.get("reason") == "no_path" and (goal_entities is None or u["entity"] in goal_entities))
|
|
82
|
+
for (entity, x, y), n in sorted(no_path.items()):
|
|
83
|
+
if n == len(runs): # no bot ever found a way: a level problem, not a skill problem
|
|
84
|
+
issues.append(f"{entity} at ({x},{y}) has no path from the player")
|
|
85
|
+
expert = stats.get("expert")
|
|
86
|
+
if expert and expert.wins == 0:
|
|
87
|
+
issues.append("even the expert bot never won — the goal may be impossible or the time too short")
|
|
88
|
+
return issues
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def markdown(rep: Report, title: str, changes: list[str] | None = None) -> str:
|
|
92
|
+
lines = [f"# Playtest — {title}", "", "| profile | wins | mean win time | deaths | top causes | missed jumps | pickups | stuck |",
|
|
93
|
+
"|---|---|---|---|---|---|---|---|"]
|
|
94
|
+
for s in rep.profiles.values():
|
|
95
|
+
causes = ", ".join(f"{k} {v}" for k, v in list(s.causes.items())[:3]) or "—"
|
|
96
|
+
lines.append(f"| {s.profile} | {s.wins}/{s.runs} | {s.mean_time or '—'}s | {s.deaths} | {causes} | "
|
|
97
|
+
f"{s.jumps_missed}/{s.jumps_intended} | {s.pickup_rate:.0%} | {s.stuck} |")
|
|
98
|
+
if rep.level_issues:
|
|
99
|
+
lines += ["", "## Level issues", *[f"- {i}" for i in rep.level_issues]]
|
|
100
|
+
if changes:
|
|
101
|
+
lines += ["", "## Tuning applied", *[f"- {c}" for c in changes]]
|
|
102
|
+
return "\n".join(lines) + "\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
CAUSE_COLORS = [(255, 0, 77), (255, 163, 0), (41, 173, 255), (0, 228, 54), (255, 119, 168), (131, 118, 156)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def heatmap(plan: dict, runs: list[dict], out: Path, cell: int = 12) -> Path:
|
|
109
|
+
"""Level drawn from the build plan; deaths as dots colored by cause, stuck spots as rings."""
|
|
110
|
+
w, h = plan["map"]["width"], plan["map"]["height"]
|
|
111
|
+
legend_h = 18
|
|
112
|
+
img = Image.new("RGB", (w * cell, h * cell + legend_h), (29, 33, 46))
|
|
113
|
+
d = ImageDraw.Draw(img, "RGBA")
|
|
114
|
+
for x, y, kind in plan["map"]["cells"]:
|
|
115
|
+
box = (x * cell, y * cell, x * cell + cell - 1, y * cell + cell - 1)
|
|
116
|
+
if kind == "solid":
|
|
117
|
+
d.rectangle(box, fill=(95, 87, 79))
|
|
118
|
+
else:
|
|
119
|
+
d.rectangle((box[0], box[1], box[2], box[1] + 2), fill=(171, 82, 54))
|
|
120
|
+
ts = plan["tile_size"]
|
|
121
|
+
for p in plan["placements"]:
|
|
122
|
+
if p["entity"] == "Player":
|
|
123
|
+
cx, cy = p["x"] / ts * cell, p["y"] / ts * cell
|
|
124
|
+
d.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), outline=(0, 228, 54), width=2)
|
|
125
|
+
causes = sorted({dd["cause"] for r in runs for dd in r["deaths"]})
|
|
126
|
+
colors = {c: CAUSE_COLORS[i % len(CAUSE_COLORS)] for i, c in enumerate(causes)}
|
|
127
|
+
for r in runs:
|
|
128
|
+
for s in r["stuck"]:
|
|
129
|
+
cx, cy = (s["x"] + 0.5) * cell, (s["y"] + 0.5) * cell
|
|
130
|
+
d.ellipse((cx - 6, cy - 6, cx + 6, cy + 6), outline=(255, 236, 39, 200), width=1)
|
|
131
|
+
for u in r.get("unreachable", []):
|
|
132
|
+
cx, cy = (u["x"] + 0.5) * cell, (u["y"] + 0.5) * cell
|
|
133
|
+
d.line((cx - 4, cy - 4, cx + 4, cy + 4), fill=(255, 255, 255, 200), width=2)
|
|
134
|
+
d.line((cx - 4, cy + 4, cx + 4, cy - 4), fill=(255, 255, 255, 200), width=2)
|
|
135
|
+
for dd in r["deaths"]:
|
|
136
|
+
# Falls happen below the map: pin them to the bottom row so they stay visible.
|
|
137
|
+
cx, cy = (min(max(dd["x"], 0), w - 1) + 0.5) * cell, (min(max(dd["y"], 0), h - 1) + 0.5) * cell
|
|
138
|
+
c = colors[dd["cause"]]
|
|
139
|
+
d.ellipse((cx - 4, cy - 4, cx + 4, cy + 4), fill=(*c, 150))
|
|
140
|
+
x = 4
|
|
141
|
+
for cause, c in colors.items():
|
|
142
|
+
d.ellipse((x, h * cell + 5, x + 8, h * cell + 13), fill=c)
|
|
143
|
+
d.text((x + 11, h * cell + 3), cause, fill=(255, 241, 232))
|
|
144
|
+
x += 16 + len(cause) * 7
|
|
145
|
+
d.text((x + 8, h * cell + 3), "o stuck x bots gave up", fill=(255, 236, 39))
|
|
146
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
img.save(out)
|
|
148
|
+
return out
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Runs the Godot playtest bots on a built project and returns their telemetry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .. import godot
|
|
11
|
+
from ..manifest import ADDON_DIR
|
|
12
|
+
|
|
13
|
+
PLAYTEST_SCENE = "res://addons/spawn_point/tools/playtest.tscn"
|
|
14
|
+
PROFILES = ("novice", "average", "expert")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sync_addon(project: Path) -> None:
|
|
18
|
+
"""Brings the project's copy of the library up to date (older builds lack telemetry hooks)."""
|
|
19
|
+
shutil.copytree(ADDON_DIR, project / "addons" / "spawn_point", dirs_exist_ok=True,
|
|
20
|
+
ignore=shutil.ignore_patterns("*.import", "*.uid", "__pycache__"))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def time_limit(graph: dict, brief: dict | None = None) -> float:
|
|
24
|
+
"""Seconds a run may take: generous for slow players, scaled to the level size and goal."""
|
|
25
|
+
rows = graph["level"]["map"]
|
|
26
|
+
width_px = max(len(r) for r in rows) * graph.get("settings", {}).get("tile_size", 32)
|
|
27
|
+
player = graph["entities"].get("Player", {}).get("components", {})
|
|
28
|
+
speed = (player.get("PlatformerMovement") or player.get("TopDownMovement") or {}).get("max_speed", 230)
|
|
29
|
+
limit = width_px / (float(speed) * 0.25) + 30 if graph["genre"] == "platformer" else 90.0
|
|
30
|
+
if brief and brief.get("goal") == "survive":
|
|
31
|
+
limit = max(limit, brief.get("survive_seconds", 60) + 15)
|
|
32
|
+
return float(min(max(limit, 45.0), 240.0))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run(project: Path, *, profiles: tuple[str, ...] = PROFILES, runs: int = 3, seed: int = 1,
|
|
36
|
+
limit_s: float = 90.0, sync: bool = True) -> list[dict]:
|
|
37
|
+
if sync:
|
|
38
|
+
sync_addon(project)
|
|
39
|
+
godot.import_project(project)
|
|
40
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
41
|
+
out = Path(tmp) / "playtest.json"
|
|
42
|
+
game_seconds = limit_s * runs * len(profiles)
|
|
43
|
+
# Bots run far faster than real time; a script error makes Godot hang, so keep a cap.
|
|
44
|
+
res = godot.run_scene(project, PLAYTEST_SCENE, [str(out), ",".join(profiles), str(runs), str(seed),
|
|
45
|
+
str(limit_s)],
|
|
46
|
+
fixed_fps=60, check=False, timeout=60 + game_seconds / 5)
|
|
47
|
+
if not out.exists():
|
|
48
|
+
raise godot.GodotError("playtest produced no report", res.output)
|
|
49
|
+
if res.script_errors:
|
|
50
|
+
raise godot.GodotError("playtest hit script errors: " + "; ".join(res.script_errors[:3]), res.output)
|
|
51
|
+
return json.loads(out.read_text())["runs"]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Deterministic tuning rules: telemetry symptoms -> bounded parameter changes (a set_params patch).
|
|
2
|
+
|
|
3
|
+
Every change is bounded relative to the *original* graph (before any tuning), so repeated rounds can
|
|
4
|
+
never drift into absurd values, and every change carries a human-readable reason.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from ..manifest import Manifest
|
|
12
|
+
from .report import Report
|
|
13
|
+
|
|
14
|
+
MOVEMENT = ("PlatformerMovement", "TopDownMovement")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def targets(difficulty: int) -> dict[str, float]:
|
|
18
|
+
"""Win-rate targets per profile; novices are allowed to struggle more on harder games."""
|
|
19
|
+
d = min(max(difficulty, 1), 5)
|
|
20
|
+
return {"novice": round(0.8 - (d - 1) * 0.125, 3), "average": 0.8, "expert": 0.95}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def score(rep: Report, difficulty: int) -> float:
|
|
24
|
+
"""Distance from the targets (lower is better). Too easy only counts on hard games."""
|
|
25
|
+
t = targets(difficulty)
|
|
26
|
+
total = 0.0
|
|
27
|
+
for prof, goal in t.items():
|
|
28
|
+
s = rep.get(prof)
|
|
29
|
+
if s is None:
|
|
30
|
+
continue
|
|
31
|
+
total += max(0.0, goal - s.win_rate) * (2.0 if prof == "expert" else 1.0)
|
|
32
|
+
if difficulty >= 3 and prof == "novice" and s.win_rate > goal + 0.35:
|
|
33
|
+
total += s.win_rate - goal - 0.35
|
|
34
|
+
return round(total, 4)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Change:
|
|
39
|
+
entity: str
|
|
40
|
+
component: str
|
|
41
|
+
param: str
|
|
42
|
+
value: float | int
|
|
43
|
+
reason: str
|
|
44
|
+
|
|
45
|
+
def to_patch(self) -> dict:
|
|
46
|
+
return {"entity": self.entity, "component": self.component, "param": self.param, "value": self.value}
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"{self.entity}.{self.component}.{self.param} = {self.value:g} — {self.reason}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Params:
|
|
53
|
+
"""Reads current and original parameter values (graph value or manifest default)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, graph: dict, original: dict, manifest: Manifest):
|
|
56
|
+
self.g, self.o, self.m = graph, original, manifest
|
|
57
|
+
|
|
58
|
+
def _get(self, graph: dict, entity: str, comp: str, param: str):
|
|
59
|
+
value = graph["entities"].get(entity, {}).get("components", {}).get(comp, {}).get(param)
|
|
60
|
+
if value is None:
|
|
61
|
+
spec = self.m.get(comp)
|
|
62
|
+
value = spec.params[param].default if spec and param in spec.params else None
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
def now(self, entity: str, comp: str, param: str):
|
|
66
|
+
return self._get(self.g, entity, comp, param)
|
|
67
|
+
|
|
68
|
+
def base(self, entity: str, comp: str, param: str):
|
|
69
|
+
return self._get(self.o, entity, comp, param)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def propose(rep: Report, graph: dict, original: dict, manifest: Manifest, difficulty: int = 2) -> list[Change]:
|
|
73
|
+
t = targets(difficulty)
|
|
74
|
+
p = _Params(graph, original, manifest)
|
|
75
|
+
ents = graph["entities"]
|
|
76
|
+
player = ents.get("Player", {}).get("components", {})
|
|
77
|
+
novice, average = rep.get("novice"), rep.get("average")
|
|
78
|
+
too_hard = bool((novice and novice.win_rate < t["novice"] - 0.1) or (average and average.win_rate < t["average"] - 0.1))
|
|
79
|
+
changes: list[Change] = []
|
|
80
|
+
|
|
81
|
+
def bump(entity, comp, param, factor=None, add=None, lo=None, hi=None, reason="", integer=False):
|
|
82
|
+
cur = p.now(entity, comp, param)
|
|
83
|
+
base = p.base(entity, comp, param)
|
|
84
|
+
if cur is None or base is None:
|
|
85
|
+
return
|
|
86
|
+
new = cur * factor if factor is not None else cur + add
|
|
87
|
+
if lo is not None:
|
|
88
|
+
new = max(new, lo(base))
|
|
89
|
+
if hi is not None:
|
|
90
|
+
new = min(new, hi(base))
|
|
91
|
+
new = int(round(new)) if integer else round(float(new), 3)
|
|
92
|
+
if new != cur:
|
|
93
|
+
changes.append(Change(entity, comp, param, new, reason))
|
|
94
|
+
|
|
95
|
+
deaths = [s for s in (novice, average) if s]
|
|
96
|
+
all_causes: dict[str, int] = {}
|
|
97
|
+
for s in deaths:
|
|
98
|
+
for k, v in s.causes.items():
|
|
99
|
+
all_causes[k] = all_causes.get(k, 0) + v
|
|
100
|
+
n_deaths = sum(all_causes.values())
|
|
101
|
+
|
|
102
|
+
# 1. Missed jumps: the player pressed jump a moment too late.
|
|
103
|
+
missed = max((s.missed_rate for s in deaths), default=0.0)
|
|
104
|
+
if "PlatformerMovement" in player and missed > 0.15:
|
|
105
|
+
why = f"{missed:.0%} of jumps were pressed too late"
|
|
106
|
+
bump("Player", "PlatformerMovement", "coyote_time", add=0.04, hi=lambda b: max(0.2, b), reason=why)
|
|
107
|
+
bump("Player", "PlatformerMovement", "jump_buffer", add=0.04, hi=lambda b: max(0.2, b), reason=why)
|
|
108
|
+
|
|
109
|
+
if too_hard and n_deaths:
|
|
110
|
+
top, top_n = max(all_causes.items(), key=lambda kv: kv[1])
|
|
111
|
+
share = top_n / n_deaths
|
|
112
|
+
falls = sum(s.falls_after_jump for s in deaths)
|
|
113
|
+
# 2. Falling into pits after jumping: more air control, slightly higher jump.
|
|
114
|
+
if "PlatformerMovement" in player and falls / n_deaths >= 0.3:
|
|
115
|
+
why = f"{falls}/{n_deaths} deaths were falls right after a jump"
|
|
116
|
+
bump("Player", "PlatformerMovement", "air_acceleration", factor=1.2, hi=lambda b: b * 2.0, reason=why)
|
|
117
|
+
bump("Player", "PlatformerMovement", "jump_velocity", factor=1.05, hi=lambda b: b * 1.15, reason=why)
|
|
118
|
+
ent = ents.get(top, {})
|
|
119
|
+
comps = ent.get("components", {})
|
|
120
|
+
mv = next((m for m in MOVEMENT if m in comps), None)
|
|
121
|
+
if share >= 0.4 and top != "fall":
|
|
122
|
+
why = f"{top} caused {top_n}/{n_deaths} deaths"
|
|
123
|
+
if mv: # 3. A moving enemy dominates: slow it down.
|
|
124
|
+
bump(top, mv, "max_speed", factor=0.85, lo=lambda b: b * 0.5, reason=why)
|
|
125
|
+
elif "Projectile" in comps: # 4. Bullets: the shooters fire less often.
|
|
126
|
+
for name, e in ents.items():
|
|
127
|
+
if e.get("components", {}).get("Weapon", {}).get("projectile") == top:
|
|
128
|
+
bump(name, "Weapon", "fire_rate", factor=0.85, lo=lambda b: b * 0.5, reason=why)
|
|
129
|
+
elif "Hitbox" in comps and "Hurtbox" in player: # 5. Static hazards: a more forgiving hurtbox.
|
|
130
|
+
bump("Player", "Hurtbox", "size_scale", add=-0.1, lo=lambda b: min(0.6, b), reason=why)
|
|
131
|
+
# 6. Still far too hard: more health and longer invincibility.
|
|
132
|
+
if novice and novice.win_rate < t["novice"] - 0.25 and "Health" in player:
|
|
133
|
+
why = f"novice bots won only {novice.wins}/{novice.runs}"
|
|
134
|
+
bump("Player", "Health", "max_health", add=1, hi=lambda b: max(6, b), reason=why, integer=True)
|
|
135
|
+
bump("Player", "Health", "invincibility_time", add=0.2, hi=lambda b: max(1.5, b), reason=why)
|
|
136
|
+
# 7. Too easy on a game meant to be hard: enemies get faster.
|
|
137
|
+
if difficulty >= 3 and novice and novice.win_rate > t["novice"] + 0.35 and novice.deaths == 0:
|
|
138
|
+
for name, e in ents.items():
|
|
139
|
+
comps = e.get("components", {})
|
|
140
|
+
mv = next((m for m in MOVEMENT if m in comps), None)
|
|
141
|
+
if mv and name != "Player" and any(k in comps for k in ("PatrolAI", "ChaseAI")):
|
|
142
|
+
bump(name, mv, "max_speed", factor=1.1, hi=lambda b: b * 1.5,
|
|
143
|
+
reason=f"too easy for difficulty {difficulty}: novices won {novice.wins}/{novice.runs} without dying")
|
|
144
|
+
# Deduplicate (first reason wins).
|
|
145
|
+
seen, out = set(), []
|
|
146
|
+
for c in changes:
|
|
147
|
+
key = (c.entity, c.component, c.param)
|
|
148
|
+
if key not in seen:
|
|
149
|
+
seen.add(key)
|
|
150
|
+
out.append(c)
|
|
151
|
+
return out
|
spawnpoint/project.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Writing the generated Godot project to disk (everything except the scenes, which Godot writes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .manifest import ADDON_DIR
|
|
11
|
+
|
|
12
|
+
AUTOLOADS = {
|
|
13
|
+
"GameState": "res://addons/spawn_point/runtime/game_state.gd",
|
|
14
|
+
"World": "res://addons/spawn_point/runtime/world.gd",
|
|
15
|
+
"FX": "res://addons/spawn_point/runtime/fx.gd",
|
|
16
|
+
"Sfx": "res://addons/spawn_point/runtime/sfx.gd",
|
|
17
|
+
"Cockpit": "res://addons/spawn_point/runtime/cockpit.gd",
|
|
18
|
+
}
|
|
19
|
+
LAYER_NAMES = ["world", "player", "enemy", "player_hurtbox", "enemy_hurtbox", "pickup"]
|
|
20
|
+
GENERATED_DIRS = ("entities", "levels", "rules", "sprites")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def slugify(name: str) -> str:
|
|
24
|
+
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
25
|
+
return s or "game"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _color(hex_color: str) -> str:
|
|
29
|
+
h = hex_color.lstrip("#")
|
|
30
|
+
r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
|
31
|
+
return f"Color({r:.4f}, {g:.4f}, {b:.4f}, 1)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def project_godot(plan: dict, godot_feature: str = "4.6") -> str:
|
|
35
|
+
vw, vh = plan["viewport"]
|
|
36
|
+
lines = [
|
|
37
|
+
"; Generated by Spawn Point. Safe to edit; rebuilding rewrites this file.",
|
|
38
|
+
"config_version=5",
|
|
39
|
+
"",
|
|
40
|
+
"[application]",
|
|
41
|
+
"",
|
|
42
|
+
f"config/name={json.dumps(plan['name'])}",
|
|
43
|
+
'run/main_scene="res://levels/main.tscn"',
|
|
44
|
+
f'config/features=PackedStringArray("{godot_feature}")',
|
|
45
|
+
"",
|
|
46
|
+
"[autoload]",
|
|
47
|
+
"",
|
|
48
|
+
*[f'{k}="*{v}"' for k, v in AUTOLOADS.items()],
|
|
49
|
+
"",
|
|
50
|
+
"[display]",
|
|
51
|
+
"",
|
|
52
|
+
f"window/size/viewport_width={vw}",
|
|
53
|
+
f"window/size/viewport_height={vh}",
|
|
54
|
+
'window/stretch/mode="canvas_items"',
|
|
55
|
+
'window/stretch/aspect="expand"',
|
|
56
|
+
"",
|
|
57
|
+
"[layer_names]",
|
|
58
|
+
"",
|
|
59
|
+
*[f'2d_physics/layer_{i + 1}="{n}"' for i, n in enumerate(LAYER_NAMES)],
|
|
60
|
+
"",
|
|
61
|
+
"[physics]",
|
|
62
|
+
"",
|
|
63
|
+
f"2d/default_gravity={float(plan['gravity'])}",
|
|
64
|
+
"",
|
|
65
|
+
"[rendering]",
|
|
66
|
+
"",
|
|
67
|
+
'renderer/rendering_method="gl_compatibility"',
|
|
68
|
+
'renderer/rendering_method.mobile="gl_compatibility"',
|
|
69
|
+
f"environment/defaults/default_clear_color={_color(plan['background'])}",
|
|
70
|
+
"textures/canvas_textures/default_texture_filter=0", # nearest: crisp pixel art
|
|
71
|
+
"",
|
|
72
|
+
]
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def write(out: Path, graph_data: dict, plan: dict, rules_source: str, godot_feature: str = "4.6",
|
|
77
|
+
sprites: dict | None = None, tiles_image=None) -> None:
|
|
78
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
for d in GENERATED_DIRS:
|
|
80
|
+
shutil.rmtree(out / d, ignore_errors=True)
|
|
81
|
+
(out / d).mkdir()
|
|
82
|
+
shutil.copytree(ADDON_DIR, out / "addons" / "spawn_point", dirs_exist_ok=True,
|
|
83
|
+
ignore=shutil.ignore_patterns("*.import", "*.uid", "__pycache__"))
|
|
84
|
+
(out / "project.godot").write_text(project_godot(plan, godot_feature))
|
|
85
|
+
(out / "build_plan.json").write_text(json.dumps(plan, indent=1))
|
|
86
|
+
(out / "rules" / "main_rules.gd").write_text(rules_source)
|
|
87
|
+
for entity, sprite in (sprites or {}).items():
|
|
88
|
+
sprite.sheet.save(out / "sprites" / f"{entity}.png")
|
|
89
|
+
if tiles_image is not None:
|
|
90
|
+
tiles_image.save(out / "sprites" / "tiles.png")
|
|
91
|
+
(out / "spawnpoint.json").write_text(json.dumps(graph_data, indent=2, ensure_ascii=False) + "\n")
|
|
92
|
+
gi = out / ".gitignore"
|
|
93
|
+
if not gi.exists():
|
|
94
|
+
gi.write_text(".godot/\n")
|
spawnpoint/py.typed
ADDED
|
File without changes
|