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,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,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)
|
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"]
|