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,89 @@
1
+ extends Node
2
+ ## Records a short gameplay clip with a simple autopilot (needs a display, not headless).
3
+ ## Run: godot4 --path <project> --fixed-fps 60 res://addons/spawn_point/tools/record.tscn -- <out_dir> <frames> <step>
4
+ ## Writes frame_0000.png, frame_0001.png, ... every <step> physics frames for <frames> frames.
5
+ ##
6
+ ## Autopilot — platformer: run right, jump at walls, gaps and enemies (double jump when airborne
7
+ ## and falling). Top-down: circle around the arena, aim at the nearest enemy and shoot.
8
+
9
+ const LEVEL := "res://levels/main.tscn"
10
+
11
+ var _jump_hold := 0
12
+ var _t := 0.0
13
+
14
+
15
+ func _ready() -> void:
16
+ var args := OS.get_cmdline_user_args()
17
+ var out_dir := args[0] if args.size() > 0 else "user://record"
18
+ var total := int(args[1]) if args.size() > 1 else 360
19
+ var step := int(args[2]) if args.size() > 2 else 3
20
+ DirAccess.make_dir_recursive_absolute(out_dir)
21
+ add_child((load(LEVEL) as PackedScene).instantiate())
22
+ await get_tree().physics_frame
23
+ var player := World.player()
24
+ var aim := SP.component(player, &"Aim") if player else null
25
+ if aim:
26
+ aim.set(&"mode", "nearest_enemy") # the mouse is not ours to move
27
+ var shot := 0
28
+ for frame in range(total):
29
+ _drive(frame)
30
+ await get_tree().physics_frame
31
+ if frame % step == 0:
32
+ await RenderingServer.frame_post_draw
33
+ get_viewport().get_texture().get_image().save_png("%s/frame_%04d.png" % [out_dir, shot])
34
+ shot += 1
35
+ get_tree().quit(0)
36
+
37
+
38
+ func _drive(frame: int) -> void:
39
+ var p := World.player() as CharacterBody2D
40
+ if p == null or frame < 20:
41
+ return
42
+ var mv := SP.movement(p)
43
+ if mv is PlatformerMovementComponent:
44
+ _platformer(p, mv as PlatformerMovementComponent)
45
+ elif mv is TopDownMovementComponent:
46
+ _topdown(p, frame)
47
+
48
+
49
+ func _platformer(p: CharacterBody2D, mv: PlatformerMovementComponent) -> void:
50
+ Input.action_press(&"move_right")
51
+ if _jump_hold > 0:
52
+ _jump_hold -= 1
53
+ if _jump_hold == 0:
54
+ Input.action_release(&"jump")
55
+ return
56
+ var half := SP.half_size(p)
57
+ var space := p.get_world_2d().direct_space_state
58
+ var ahead := p.global_position + Vector2(half.x + 26, 0)
59
+ var wall := not space.intersect_ray(PhysicsRayQueryParameters2D.create(p.global_position, ahead, SP.LAYER_WORLD, [p.get_rid()])).is_empty()
60
+ var ground := not space.intersect_ray(PhysicsRayQueryParameters2D.create(ahead, ahead + Vector2(0, half.y + 40), SP.LAYER_WORLD, [p.get_rid()])).is_empty()
61
+ var enemy := false
62
+ for e in p.get_tree().get_nodes_in_group(&"team_enemy"):
63
+ var d := (e as Node2D).global_position - p.global_position
64
+ if d.x > 0 and d.x < 90 and absf(d.y) < 48:
65
+ enemy = true
66
+ var falling_into_gap := not p.is_on_floor() and p.velocity.y > 120 and not ground
67
+ if (p.is_on_floor() and (wall or not ground or enemy)) or (falling_into_gap and mv.air_jumps > 0):
68
+ Input.action_press(&"jump")
69
+ _jump_hold = 14
70
+
71
+
72
+ func _topdown(p: CharacterBody2D, frame: int) -> void:
73
+ _t += 1.0 / 60.0
74
+ var dir := Vector2(cos(_t * 0.9), sin(_t * 1.3))
75
+ for a in [&"move_left", &"move_right", &"move_up", &"move_down"]:
76
+ Input.action_release(a)
77
+ if dir.x < -0.3:
78
+ Input.action_press(&"move_left")
79
+ elif dir.x > 0.3:
80
+ Input.action_press(&"move_right")
81
+ if dir.y < -0.3:
82
+ Input.action_press(&"move_up")
83
+ elif dir.y > 0.3:
84
+ Input.action_press(&"move_down")
85
+ Input.action_press(&"shoot")
86
+ if frame % 120 == 60:
87
+ Input.action_press(&"dash")
88
+ elif frame % 120 == 64:
89
+ Input.action_release(&"dash")
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/record.gd" id="1_record"]
4
+
5
+ [node name="Record" type="Node"]
6
+ script = ExtResource("1_record")
@@ -0,0 +1,72 @@
1
+ extends Node
2
+ ## Smoke test for a generated game: loads the main level, plays a few seconds with scripted
3
+ ## input (idle, run right, jump, shoot, dash), and prints a JSON report line:
4
+ ## SPAWNPOINT_SMOKE {...}
5
+ ## Run: godot4 --headless --path <project> --fixed-fps 60 res://addons/spawn_point/tools/smoke.tscn
6
+
7
+ const LEVEL := "res://levels/main.tscn"
8
+
9
+
10
+ func _ready() -> void:
11
+ var report := {}
12
+ var scene := load(LEVEL) as PackedScene
13
+ if scene == null:
14
+ _finish({"error": "cannot load " + LEVEL})
15
+ return
16
+ var level := scene.instantiate()
17
+ add_child(level)
18
+ await _frames(60)
19
+
20
+ var player := World.player()
21
+ report["player_found"] = player != null
22
+ report["entity_counts"] = _counts()
23
+ if player == null:
24
+ _finish(report)
25
+ return
26
+ var cb := player as CharacterBody2D
27
+ report["on_floor_after_1s"] = cb.is_on_floor() if cb else false
28
+ var start := player.global_position
29
+
30
+ Input.action_press(&"move_right")
31
+ for i in 90:
32
+ if i % 30 == 0:
33
+ Input.action_press(&"jump")
34
+ elif i % 30 == 12:
35
+ Input.action_release(&"jump")
36
+ await _frames(1)
37
+ Input.action_release(&"move_right")
38
+ Input.action_release(&"jump")
39
+ Input.action_press(&"shoot")
40
+ Input.action_press(&"dash")
41
+ await _frames(20)
42
+ Input.action_release(&"shoot")
43
+ Input.action_release(&"dash")
44
+ await _frames(40)
45
+
46
+ player = World.player()
47
+ report["player_alive"] = player != null and player.visible
48
+ report["moved_px"] = player.global_position.distance_to(start) if player else 0.0
49
+ report["score"] = GameState.score
50
+ report["lives"] = GameState.lives
51
+ report["phase"] = ["playing", "won", "lost"][GameState.phase]
52
+ _finish(report)
53
+
54
+
55
+ func _counts() -> Dictionary:
56
+ var out := {}
57
+ for n in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
58
+ var c := SP.core(n)
59
+ if c:
60
+ out[c.entity_type] = out.get(c.entity_type, 0) + 1
61
+ return out
62
+
63
+
64
+ func _frames(n: int) -> void:
65
+ for i in n:
66
+ await get_tree().physics_frame
67
+
68
+
69
+ func _finish(report: Dictionary) -> void:
70
+ print("SPAWNPOINT_SMOKE ", JSON.stringify(report))
71
+ Engine.time_scale = 1.0
72
+ get_tree().quit(0)
@@ -0,0 +1,6 @@
1
+ [gd_scene format=3]
2
+
3
+ [ext_resource type="Script" path="res://addons/spawn_point/tools/smoke.gd" id="1_smoke"]
4
+
5
+ [node name="Smoke" type="Node"]
6
+ script = ExtResource("1_smoke")
spawnpoint/analyze.py ADDED
@@ -0,0 +1,387 @@
1
+ """Semantic analysis: graph + manifest -> diagnostics + build plan (the IR consumed by build.gd)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from . import level as lvl
11
+ from .diagnostics import Diagnostics, did_you_mean, join_path
12
+ from .graph import Entity, Graph, parse
13
+ from .manifest import BODY_CLASSES, Component, Manifest, Param
14
+ from .rules import compile_rules
15
+ from .sprites import resolve as sprite_resolve
16
+ from .sprites.spriteset import SpriteError, SpriteSet
17
+
18
+ PLAN_FORMAT = 1
19
+ ENTITY_NAME = re.compile(r"^[A-Z][A-Za-z0-9]*$")
20
+ RESERVED = {"GameState", "Game", "World", "FX", "Sfx", "SP", "Entity", "Visual", "Shape", "Main", "Tiles",
21
+ "Entities", "Rules", "HUD", "Camera"}
22
+
23
+ # PICO-8 inspired placeholder palette.
24
+ COLORS = {
25
+ "player": "#29adff", "enemy": "#ff004d", "collectible": "#ffec27", "bullet_player": "#fff1e8",
26
+ "bullet_enemy": "#ff77a8", "gate": "#ab5236", "trigger": "#00e436", "other": "#c2c3c7",
27
+ }
28
+ GENRE_DEFAULTS = {
29
+ "platformer": {"gravity": 1600.0, "background": "#1d2b53", "solid": "#5f574f", "solid_top": "#00e436",
30
+ "oneway": "#ab5236", "walls": ["left", "right", "top"]},
31
+ "topdown": {"gravity": 0.0, "background": "#1a1c2c", "solid": "#5f574f", "solid_top": "#83769c",
32
+ "oneway": "#ab5236", "walls": ["left", "right", "top", "bottom"]},
33
+ }
34
+ MOVEMENT = {"PlatformerMovement", "TopDownMovement"}
35
+ RULES_SCRIPT = "res://rules/main_rules.gd"
36
+
37
+ # Layer bits — keep in sync with runtime/sp.gd.
38
+ L_WORLD, L_PLAYER, L_ENEMY, L_PICKUP = 1, 2, 4, 32
39
+
40
+
41
+ @dataclass
42
+ class Analysis:
43
+ graph: Graph | None
44
+ diagnostics: Diagnostics
45
+ plan: dict | None = None
46
+ rules_source: str | None = None
47
+ sprites: dict[str, SpriteSet] = field(default_factory=dict) # entity -> resolved sprite sheet
48
+ tiles_image: Any = None # pixel-art tile atlas (PIL image), written to res://sprites/tiles.png
49
+
50
+ @property
51
+ def ok(self) -> bool:
52
+ return self.diagnostics.ok and self.plan is not None
53
+
54
+
55
+ def analyze(data: Any, manifest: Manifest, base_dir: Path | None = None) -> Analysis:
56
+ """`base_dir` resolves relative sprite paths (the graph file's folder)."""
57
+ graph, diags = parse(data)
58
+ if graph is None:
59
+ return Analysis(None, diags)
60
+ return _Analyzer(graph, manifest, diags, base_dir).run()
61
+
62
+
63
+ class _Analyzer:
64
+ def __init__(self, graph: Graph, manifest: Manifest, diags: Diagnostics, base_dir: Path | None = None):
65
+ self.g = graph
66
+ self.m = manifest
67
+ self.d = diags
68
+ self.base_dir = base_dir
69
+ self.genre = GENRE_DEFAULTS[graph.genre]
70
+ self.sprites: dict[str, SpriteSet] = {}
71
+ self.tiles_image = None
72
+
73
+ # -- entry -----------------------------------------------------------------------------
74
+ def run(self) -> Analysis:
75
+ g = self.g
76
+ comps_by_entity = {name: list(e.components) for name, e in g.entities.items()}
77
+ teams = {name: self._team(e) for name, e in g.entities.items()}
78
+ self._resolve_sprites(teams)
79
+ params_by_entity: dict[str, dict[str, dict]] = {}
80
+ for name, ent in g.entities.items():
81
+ params_by_entity[name] = self._check_entity(name, ent)
82
+
83
+ players = [n for n, e in g.entities.items() if "PlayerInput" in e.components]
84
+ if not players:
85
+ self.d.error("E_NO_PLAYER", "entities", "no entity has a PlayerInput component",
86
+ 'Add "PlayerInput": {} to the player entity')
87
+ elif len(players) > 1:
88
+ self.d.error("E_MULTIPLE_PLAYERS", "entities", f"several entities have PlayerInput: {', '.join(players)}")
89
+
90
+ data = lvl.parse_level(g.level, set(g.entities), self.d)
91
+ if data is not None and len(players) == 1:
92
+ self._check_level(data, players[0], params_by_entity[players[0]])
93
+
94
+ rules = compile_rules(g.rules, comps_by_entity, self.m, self.d)
95
+ if not rules.calls_win and self.d.ok:
96
+ self.d.warning("W_NO_WIN", "rules", "no rule calls win(); the game cannot be won",
97
+ 'e.g. {"on": "Exit.triggered", "do": ["win()"]}')
98
+
99
+ if not self.d.ok or data is None:
100
+ return Analysis(g, self.d)
101
+ plan = self._plan(data, teams, params_by_entity, players[0])
102
+ return Analysis(g, self.d, plan, rules.source, self.sprites, self.tiles_image)
103
+
104
+ # -- sprites ---------------------------------------------------------------------------
105
+ def _resolve_sprites(self, teams: dict[str, str]) -> None:
106
+ for name, ent in self.g.entities.items():
107
+ if ent.sprite is None:
108
+ continue
109
+ color = ent.shape.color or self._color(ent, teams[name])
110
+ try:
111
+ path = join_path("entities", name, "sprite")
112
+ sprite = sprite_resolve.resolve(name, ent, self.base_dir, color,
113
+ warn=lambda m, p=path: self.d.warning("W_AI_SPRITE", p, m))
114
+ except SpriteError as e:
115
+ self.d.error("E_SPRITE", join_path("entities", name, "sprite"), str(e),
116
+ "Generator kinds: " + ", ".join(sorted(sprite_resolve.KINDS)) if "kind" in str(e) else None)
117
+ continue
118
+ self.sprites[name] = sprite
119
+ if "shape" not in ent.model_fields_set:
120
+ # No explicit shape: the body collision comes from the drawing.
121
+ ent.shape = sprite_resolve.derived_shape(ent, sprite, self.g.genre)
122
+
123
+ # -- entities --------------------------------------------------------------------------
124
+ def _team(self, e: Entity) -> str:
125
+ if e.team:
126
+ return e.team
127
+ c = e.components
128
+ if "PlayerInput" in c:
129
+ return "player"
130
+ if any(k in c for k in ("Hitbox", "PatrolAI", "ChaseAI")):
131
+ return "enemy"
132
+ return "neutral"
133
+
134
+ def _check_entity(self, name: str, ent: Entity) -> dict[str, dict]:
135
+ path = join_path("entities", name)
136
+ if not ENTITY_NAME.match(name):
137
+ self.d.error("E_ENTITY_NAME", path, f"entity name '{name}' must be PascalCase (e.g. 'BlueSlime')")
138
+ if name in RESERVED or name in self.m.components:
139
+ self.d.error("E_ENTITY_NAME", path, f"'{name}' is reserved", f"Rename it, e.g. '{name}Entity'")
140
+ body_class = BODY_CLASSES[ent.body]
141
+ resolved: dict[str, dict] = {}
142
+ movement = [c for c in ent.components if c in MOVEMENT]
143
+ if len(movement) > 1:
144
+ self.d.error("E_MOVEMENT", join_path(path, "components"), "an entity can have only one movement component")
145
+ if self.g.genre == "platformer" and "TopDownMovement" in ent.components:
146
+ self.d.warning("W_GENRE", join_path(path, "components"), "TopDownMovement in a platformer ignores gravity")
147
+ if self.g.genre == "topdown" and "PlatformerMovement" in ent.components:
148
+ self.d.warning("W_GENRE", join_path(path, "components"), "PlatformerMovement in a top-down game falls forever")
149
+
150
+ for cname, params in ent.components.items():
151
+ cpath = join_path(path, "components", cname)
152
+ comp = self.m.get(cname)
153
+ if comp is None:
154
+ self.d.error("E_UNKNOWN_COMPONENT", cpath, f"unknown component '{cname}'",
155
+ did_you_mean(cname, self.m.components))
156
+ continue
157
+ if comp.hosts and body_class not in comp.hosts:
158
+ want = " or ".join(k for k, v in BODY_CLASSES.items() if v in comp.hosts)
159
+ self.d.error("E_HOST", cpath, f"{cname} needs body '{want}', but {name} is '{ent.body}'",
160
+ f'Set "body": "{want}" on {name}')
161
+ for req in comp.requires:
162
+ if req not in ent.components:
163
+ self.d.error("E_REQUIRES", cpath, f"{cname} requires the {req} component", f'Add "{req}": {{}}')
164
+ if comp.is_area and ent.shape.type == "none":
165
+ self.d.error("E_SHAPE", cpath, f"{cname} needs a collision shape but {name} has shape 'none'")
166
+ resolved[cname] = self._check_params(comp, params or {}, cpath)
167
+
168
+ if "Projectile" in ent.components and "Hitbox" not in ent.components:
169
+ self.d.warning("W_PROJECTILE_HITBOX", path, f"{name} is a projectile without a Hitbox; it deals no damage",
170
+ 'Add "Hitbox": {"damage": 1}')
171
+
172
+ if isinstance(ent.juice, dict):
173
+ juice = self.m.get("Juice")
174
+ if juice:
175
+ resolved["Juice"] = self._check_params(juice, ent.juice, join_path(path, "juice"))
176
+ return resolved
177
+
178
+ def _check_params(self, comp: Component, params: dict[str, Any], path: str) -> dict:
179
+ out: dict[str, Any] = {}
180
+ for key, value in params.items():
181
+ ppath = join_path(path, key)
182
+ p = comp.params.get(key)
183
+ if p is None:
184
+ self.d.error("E_UNKNOWN_PARAM", ppath, f"{comp.name} has no parameter '{key}'",
185
+ did_you_mean(key, comp.params))
186
+ continue
187
+ converted = self._convert(p, value, ppath)
188
+ if converted is None:
189
+ continue
190
+ if key in comp.entity_params and converted not in self.g.entities:
191
+ self.d.error("E_ENTITY_REF", ppath, f"'{converted}' is not an entity", did_you_mean(converted, self.g.entities))
192
+ continue
193
+ if key in comp.entity_params and comp.name == "Weapon":
194
+ target = self.g.entities[converted]
195
+ if "Projectile" not in target.components:
196
+ self.d.error("E_NOT_PROJECTILE", ppath, f"Weapon fires '{converted}', which has no Projectile component")
197
+ out[key] = converted
198
+ for key in comp.entity_params:
199
+ if key not in params:
200
+ default = comp.params[key].default
201
+ if default not in self.g.entities:
202
+ self.d.error("E_ENTITY_REF", join_path(path, key),
203
+ f"{comp.name}.{key} must name an entity (default '{default}' does not exist)")
204
+ return out
205
+
206
+ def _convert(self, p: Param, v: Any, path: str) -> Any:
207
+ def bad(expected: str) -> None:
208
+ self.d.error("E_PARAM_TYPE", path, f"{p.name} must be {expected}, got {v!r}")
209
+
210
+ t = p.type
211
+ if t == "bool":
212
+ if not isinstance(v, bool):
213
+ return bad("true or false")
214
+ return v
215
+ if t in ("int", "float"):
216
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
217
+ return bad("a number")
218
+ if t == "int":
219
+ if float(v) != int(v):
220
+ return bad("a whole number")
221
+ v = int(v)
222
+ else:
223
+ v = float(v)
224
+ if p.min is not None and not (p.min <= v <= p.max):
225
+ self.d.error("E_PARAM_RANGE", path, f"{p.name} = {v:g} is outside {p.min:g}..{p.max:g}")
226
+ return None
227
+ return v
228
+ if t in ("String", "StringName"):
229
+ if not isinstance(v, str):
230
+ return bad("a string")
231
+ if p.choices and v not in p.choices:
232
+ self.d.error("E_PARAM_CHOICE", path, f"{p.name} must be one of {', '.join(p.choices)}",
233
+ did_you_mean(v, p.choices))
234
+ return None
235
+ return v
236
+ if t == "Vector2":
237
+ if not (isinstance(v, list) and len(v) == 2 and all(isinstance(x, (int, float)) for x in v)):
238
+ return bad("[x, y]")
239
+ return [float(v[0]), float(v[1])]
240
+ if t == "Color":
241
+ if not (isinstance(v, str) and re.match(r"^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$", v)):
242
+ return bad("a hex color like '#ff004d'")
243
+ return v
244
+ self.d.error("E_PARAM_TYPE", path, f"{p.name} has unsupported type {t}")
245
+ return None
246
+
247
+ # -- level -----------------------------------------------------------------------------
248
+ def _check_level(self, data: lvl.LevelData, player: str, player_params: dict[str, dict]) -> None:
249
+ mp = join_path("level", "map")
250
+ spots = [p for p in data.placements if p.entity == player]
251
+ if len(spots) != 1:
252
+ self.d.error("E_PLAYER_PLACEMENT", mp, f"the player '{player}' must appear exactly once on the map (found {len(spots)})",
253
+ "Put its legend character once in level.map")
254
+ return
255
+ start = spots[0]
256
+ if self.g.genre != "platformer":
257
+ return
258
+ if not lvl.has_ground_below(data, start.x, start.y):
259
+ self.d.warning("W_NO_GROUND", mp, f"no floor below the player at ({start.x},{start.y}); they will fall out")
260
+ return
261
+ ent = self.g.entities[player]
262
+ mv = self.m.get("PlatformerMovement")
263
+ if "PlatformerMovement" not in ent.components or mv is None:
264
+ return
265
+ mv_params = player_params.get("PlatformerMovement", {}) # validated values only
266
+
267
+ def param(name: str) -> float:
268
+ return float(mv_params.get(name, mv.params[name].default))
269
+
270
+ gravity = (self.g.settings.gravity if self.g.settings.gravity is not None else self.genre["gravity"]) * param("gravity_scale")
271
+ jm = lvl.jump_model(param("jump_velocity"), gravity, param("fall_gravity_multiplier"), param("max_speed"),
272
+ self.g.settings.tile_size, ent.shape.size[1], int(param("air_jumps")))
273
+ targets = [p for p in data.placements
274
+ if any(c in self.g.entities[p.entity].components for c in ("Collectible", "TriggerZone"))]
275
+ for t in lvl.unreachable(data, (start.x, start.y), targets, jm):
276
+ self.d.warning("W_UNREACHABLE", mp, f"{t.entity} at ({t.x},{t.y}) looks unreachable "
277
+ f"(jump ≈ {jm.up} tiles up, {jm.across} across)", "Lower it, add a platform, or raise jump_velocity")
278
+
279
+ # -- plan ------------------------------------------------------------------------------
280
+ def _plan(self, data: lvl.LevelData, teams: dict[str, str], params: dict[str, dict], player: str) -> dict:
281
+ g, s = self.g, self.g.settings
282
+ ts = s.tile_size
283
+ juice_on = s.juice == "default"
284
+ entities = []
285
+ for name, ent in g.entities.items():
286
+ team = teams[name]
287
+ comps = []
288
+ for cname in ent.components:
289
+ comp = self.m.get(cname)
290
+ p = params[name].get(cname, {})
291
+ area_scale = None
292
+ if comp.is_area:
293
+ area_scale = float(p.get("size_scale", comp.params["size_scale"].default)) if "size_scale" in comp.params else 1.0
294
+ comps.append({"name": cname, "script": comp.script, "params": p, "area_scale": area_scale})
295
+ if juice_on and ent.juice != "none":
296
+ comps.append({"name": "Juice", "script": self.m.get("Juice").script,
297
+ "params": params[name].get("Juice", {}), "area_scale": None})
298
+ sprite = self.sprites.get(name)
299
+ if sprite is not None and self.m.get("Animator"):
300
+ comps.append({"name": "Animator", "script": self.m.get("Animator").script, "params": {},
301
+ "area_scale": None})
302
+ entities.append({
303
+ "name": name,
304
+ "body": ent.body,
305
+ "team": team,
306
+ "is_player": name == player,
307
+ "layer": self._layer(ent, team),
308
+ "mask": self._mask(ent),
309
+ "shape": {"type": ent.shape.type, "size": list(ent.shape.size),
310
+ "color": ent.shape.color or self._color(ent, team)},
311
+ "eye": ent.body == "character" and sprite is None,
312
+ "pivot": "bottom" if g.genre == "platformer" and ent.body == "character" else "center",
313
+ "sprite": sprite_resolve.plan_entry(name, sprite) if sprite is not None else None,
314
+ })
315
+ entities[-1]["components"] = comps
316
+
317
+ placements = []
318
+ for p in data.placements:
319
+ ent = g.entities[p.entity]
320
+ x = (p.x + 0.5) * ts
321
+ floating = any(c in ent.components for c in ("Collectible", "Projectile", "Spawner"))
322
+ if g.genre == "platformer" and not floating:
323
+ y = (p.y + 1) * ts - ent.shape.size[1] / 2
324
+ else:
325
+ y = (p.y + 0.5) * ts
326
+ placements.append({"entity": p.entity, "x": x, "y": y})
327
+
328
+ solid, oneway = s.tiles.get("solid"), s.tiles.get("oneway")
329
+ tiles_style = {
330
+ "solid": {"color": (solid and solid.color) or self.genre["solid"],
331
+ "top": (solid and solid.top) or self.genre["solid_top"]},
332
+ "oneway": {"color": (oneway and oneway.color) or self.genre["oneway"]},
333
+ }
334
+ if s.tile_style == "pixel":
335
+ from .sprites.tiles import tileset
336
+ self.tiles_image = tileset(g.genre, tiles_style["solid"]["color"], tiles_style["solid"]["top"],
337
+ tiles_style["oneway"]["color"], ts, s.seed)
338
+ tiles_style["texture"] = "res://sprites/tiles.png"
339
+ return {
340
+ "format": PLAN_FORMAT,
341
+ "name": g.name,
342
+ "genre": g.genre,
343
+ "tile_size": ts,
344
+ "lives": s.lives,
345
+ "viewport": list(s.viewport),
346
+ "zoom": s.zoom,
347
+ "background": s.background or self.genre["background"],
348
+ "gravity": s.gravity if s.gravity is not None else self.genre["gravity"],
349
+ "tiles": tiles_style,
350
+ "map": {"width": data.width, "height": data.height,
351
+ "cells": [[x, y, k] for (x, y), k in sorted(data.tiles.items(), key=lambda kv: (kv[0][1], kv[0][0]))]},
352
+ "bounds": [0, 0, data.width * ts, data.height * ts],
353
+ "walls": self.genre["walls"],
354
+ "entities": entities,
355
+ "placements": placements,
356
+ "rules_script": RULES_SCRIPT,
357
+ }
358
+
359
+ def _layer(self, ent: Entity, team: str) -> int:
360
+ if ent.body == "character":
361
+ return {"player": L_PLAYER, "enemy": L_ENEMY}.get(team, 0)
362
+ if ent.body == "static":
363
+ return L_WORLD
364
+ return 0 if "Projectile" in ent.components else L_PICKUP
365
+
366
+ def _mask(self, ent: Entity) -> int:
367
+ if ent.body == "character":
368
+ return L_WORLD
369
+ if ent.body == "static":
370
+ return 0
371
+ return L_WORLD if "Projectile" in ent.components else (L_PLAYER | L_ENEMY)
372
+
373
+ def _color(self, ent: Entity, team: str) -> str:
374
+ c = ent.components
375
+ if "PlayerInput" in c:
376
+ return COLORS["player"]
377
+ if "Projectile" in c:
378
+ return COLORS["bullet_enemy"] if team == "enemy" else COLORS["bullet_player"]
379
+ if "Collectible" in c:
380
+ return COLORS["collectible"]
381
+ if "Gate" in c:
382
+ return COLORS["gate"]
383
+ if "TriggerZone" in c:
384
+ return COLORS["trigger"]
385
+ if team == "enemy":
386
+ return COLORS["enemy"]
387
+ return COLORS["other"]