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.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. 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