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
tests/test_manifest.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from spawnpoint import godot, manifest as manifest_mod
|
|
6
|
+
from spawnpoint.manifest import parse_docs
|
|
7
|
+
|
|
8
|
+
SOURCE = '''class_name FooComponent
|
|
9
|
+
extends Node
|
|
10
|
+
## Does foo.
|
|
11
|
+
## Second line.
|
|
12
|
+
|
|
13
|
+
const SP_NAME := "Foo"
|
|
14
|
+
|
|
15
|
+
## Fired on foo.
|
|
16
|
+
signal fooed(amount: int)
|
|
17
|
+
|
|
18
|
+
## How much foo.
|
|
19
|
+
@export_range(0, 10) var amount: int = 3
|
|
20
|
+
var _private := 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## Foos now.
|
|
24
|
+
func foo() -> void:
|
|
25
|
+
pass
|
|
26
|
+
'''
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_parse_docs():
|
|
30
|
+
docs = parse_docs(SOURCE)
|
|
31
|
+
assert docs["@class"] == "Does foo. Second line."
|
|
32
|
+
assert docs["fooed"] == "Fired on foo."
|
|
33
|
+
assert docs["amount"] == "How much foo."
|
|
34
|
+
assert docs["foo"] == "Foos now."
|
|
35
|
+
assert "_private" not in docs
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_manifest_contents(manifest):
|
|
39
|
+
pm = manifest.get("PlatformerMovement")
|
|
40
|
+
assert pm.hosts == ["CharacterBody2D"]
|
|
41
|
+
assert pm.params["coyote_time"].max == 0.5
|
|
42
|
+
assert "step_velocity" not in pm.methods # internal
|
|
43
|
+
assert pm.signals["landed"].args[0].name == "impact_speed"
|
|
44
|
+
assert manifest.get("Aim").params["mode"].choices == ("mouse", "nearest_enemy", "movement")
|
|
45
|
+
assert manifest.get("Health").state["current"] == "int"
|
|
46
|
+
assert all(c.doc for c in manifest.components.values())
|
|
47
|
+
assert all(p.doc for c in manifest.components.values() for p in c.params.values())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
51
|
+
def test_manifest_is_up_to_date(tmp_path):
|
|
52
|
+
fresh = manifest_mod.generate(out=tmp_path / "manifest.json")
|
|
53
|
+
committed = json.loads(manifest_mod.MANIFEST_PATH.read_text())
|
|
54
|
+
assert fresh == committed, "manifest.json is stale: run `spawnpoint manifest`"
|
tests/test_playtest.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
from spawnpoint import godot
|
|
7
|
+
from spawnpoint.build import build
|
|
8
|
+
from spawnpoint.playtest import goal_entities, merge_tuning, playtest
|
|
9
|
+
from spawnpoint.playtest.report import aggregate, heatmap, markdown
|
|
10
|
+
from spawnpoint.playtest.runner import time_limit
|
|
11
|
+
from spawnpoint.playtest.tuner import Change, propose, score, targets
|
|
12
|
+
|
|
13
|
+
from conftest import load_example
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(profile, outcome="won", deaths=(), missed=0, intended=10, collected=5, total=5, unreachable=()):
|
|
17
|
+
return {"profile": profile, "run": 0, "outcome": outcome, "time": 20.0, "score": 0, "lives_left": 1,
|
|
18
|
+
"deaths": [{"x": x, "y": 5, "cause": c, "t": 1.0, "after_jump": aj} for c, x, aj in deaths],
|
|
19
|
+
"damage": [], "stuck": [], "unreachable": list(unreachable),
|
|
20
|
+
"jumps": {"intended": intended, "performed": intended - missed, "missed": [{"x": 0, "y": 0}] * missed},
|
|
21
|
+
"pickups": {"total": total, "collected": collected}}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def graph():
|
|
25
|
+
g = load_example("pixel_hopper.json")
|
|
26
|
+
return json.loads(json.dumps(g))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_aggregate_and_markdown():
|
|
30
|
+
rep = aggregate([run("novice", "lost", [("Spikes", 3, False), ("Spikes", 4, False), ("fall", 9, True)], missed=4),
|
|
31
|
+
run("novice"), run("expert")])
|
|
32
|
+
n = rep.get("novice")
|
|
33
|
+
assert n.win_rate == 0.5 and n.deaths == 3 and n.causes == {"Spikes": 2, "fall": 1}
|
|
34
|
+
assert n.falls_after_jump == 1 and n.missed_rate == 0.2
|
|
35
|
+
md = markdown(rep, "Test", ["Player.Health.max_health = 4 — reason"])
|
|
36
|
+
assert "| novice | 1/2 |" in md and "Tuning applied" in md
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_level_issues_only_for_goal_entities():
|
|
40
|
+
u = [{"entity": "Coin", "x": 3, "y": 2, "reason": "no_path"}, {"entity": "Slime", "x": 9, "y": 2, "reason": "no_path"}]
|
|
41
|
+
rep = aggregate([run("expert", "timeout", unreachable=u)], {"Coin"})
|
|
42
|
+
assert rep.level_issues == ["Coin at (3,2) has no path from the player",
|
|
43
|
+
"even the expert bot never won — the goal may be impossible or the time too short"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_targets_and_score():
|
|
47
|
+
assert targets(1)["novice"] == 0.8 and targets(5)["novice"] == 0.3
|
|
48
|
+
good = aggregate([run("novice"), run("average"), run("expert")])
|
|
49
|
+
bad = aggregate([run("novice", "lost"), run("average", "lost"), run("expert")])
|
|
50
|
+
assert score(good, 2) == 0 and score(bad, 2) > 1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_rule_missed_jumps_raise_coyote_and_buffer(manifest):
|
|
54
|
+
g = graph()
|
|
55
|
+
rep = aggregate([run("novice", missed=5, intended=10), run("average")])
|
|
56
|
+
changes = {(c.entity, c.param): c.value for c in propose(rep, g, g, manifest)}
|
|
57
|
+
assert changes[("Player", "coyote_time")] == pytest.approx(0.14)
|
|
58
|
+
assert changes[("Player", "jump_buffer")] == pytest.approx(0.16)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_rule_dominant_enemy_slows_down(manifest):
|
|
62
|
+
g = graph()
|
|
63
|
+
rep = aggregate([run("novice", "lost", [("Slime", 3, False)] * 3), run("average", "lost", [("Slime", 4, False)])])
|
|
64
|
+
changes = {(c.entity, c.component, c.param): c.value for c in propose(rep, g, g, manifest)}
|
|
65
|
+
assert changes[("Slime", "PlatformerMovement", "max_speed")] == pytest.approx(51.0)
|
|
66
|
+
assert changes[("Player", "Health", "max_health")] == 4
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_rule_spikes_shrink_hurtbox_and_bounds(manifest):
|
|
70
|
+
g = graph()
|
|
71
|
+
g["entities"]["Player"]["components"]["Hurtbox"] = {"size_scale": 0.6}
|
|
72
|
+
rep = aggregate([run("novice", "lost", [("Spikes", 3, False)] * 3), run("average", "lost", [("Spikes", 3, False)])])
|
|
73
|
+
keys = {(c.entity, c.component, c.param) for c in propose(rep, g, g, manifest)}
|
|
74
|
+
assert ("Player", "Hurtbox", "size_scale") not in keys # already at the floor
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_rule_bullets_lower_fire_rate(manifest):
|
|
78
|
+
g = load_example("pixel_arena.json")
|
|
79
|
+
rep = aggregate([run("novice", "lost", [("EnemyBullet", 3, False)] * 3), run("average", "lost", [("EnemyBullet", 3, False)])])
|
|
80
|
+
changes = {(c.entity, c.param): c.value for c in propose(rep, g, g, manifest)}
|
|
81
|
+
assert changes[("Shooter", "fire_rate")] == pytest.approx(1.3 * 0.85, abs=0.01)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_rule_too_easy_only_on_hard_games(manifest):
|
|
85
|
+
g = graph()
|
|
86
|
+
rep = aggregate([run("novice"), run("novice"), run("average"), run("expert")])
|
|
87
|
+
assert propose(rep, g, g, manifest, difficulty=2) == []
|
|
88
|
+
hard = {(c.entity, c.param) for c in propose(rep, g, g, manifest, difficulty=4)}
|
|
89
|
+
assert ("Slime", "max_speed") in hard and ("Frog", "max_speed") in hard
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_bounds_are_relative_to_the_original(manifest):
|
|
93
|
+
original = graph()
|
|
94
|
+
tuned = graph()
|
|
95
|
+
tuned["entities"]["Slime"]["components"]["PlatformerMovement"]["max_speed"] = 31
|
|
96
|
+
rep = aggregate([run("novice", "lost", [("Slime", 3, False)] * 3), run("average", "lost", [("Slime", 3, False)])])
|
|
97
|
+
c = next(c for c in propose(rep, tuned, original, manifest) if c.entity == "Slime")
|
|
98
|
+
assert c.value == 30 # floor: 50% of the original 60
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_merge_tuning_latest_wins():
|
|
102
|
+
old = [{"entity": "Player", "component": "Health", "param": "max_health", "value": 4}]
|
|
103
|
+
new = merge_tuning(old, [Change("Player", "Health", "max_health", 5, "r"), Change("Slime", "PatrolAI", "speed_scale", 0.8, "r")])
|
|
104
|
+
assert {(t["entity"], t["value"]) for t in new} == {("Player", 5), ("Slime", 0.8)}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_time_limit_and_goal_entities():
|
|
108
|
+
g = graph()
|
|
109
|
+
assert 45 <= time_limit(g) <= 240
|
|
110
|
+
assert time_limit(load_example("pixel_arena.json"), {"goal": "survive", "survive_seconds": 120}) == 135
|
|
111
|
+
assert goal_entities(g) == {"Coin", "Heart", "Exit"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_heatmap_renders(tmp_path, manifest):
|
|
115
|
+
from spawnpoint.analyze import analyze
|
|
116
|
+
plan = analyze(graph(), manifest).plan
|
|
117
|
+
out = heatmap(plan, [run("novice", "lost", [("Spikes", 3, False), ("fall", 20, True)])], tmp_path / "h.png")
|
|
118
|
+
img = Image.open(out)
|
|
119
|
+
assert img.width == plan["map"]["width"] * 12
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.skipif(not godot.available(), reason="Godot not installed")
|
|
123
|
+
def test_playtest_end_to_end(tmp_path):
|
|
124
|
+
out = tmp_path / "game"
|
|
125
|
+
assert build(load_example("pixel_hopper.json"), out).ok
|
|
126
|
+
res = playtest(out, runs=1, profiles=("expert",))
|
|
127
|
+
exp = res.final.get("expert")
|
|
128
|
+
assert exp.runs == 1 and exp.wins == 1, res.final.to_dict()["profiles"]
|
|
129
|
+
assert (out / "playtest" / "heatmap.png").exists() and (out / "playtest" / "report.md").exists()
|
tests/test_rules.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from spawnpoint.diagnostics import Diagnostics
|
|
4
|
+
from spawnpoint.expr import ExprError
|
|
5
|
+
from spawnpoint.graph import Rule
|
|
6
|
+
from spawnpoint.rules import compile_rules, resolve_event
|
|
7
|
+
|
|
8
|
+
ENTITIES = {
|
|
9
|
+
"Player": ["PlatformerMovement", "PlayerInput", "Health", "Hurtbox", "Stomp"],
|
|
10
|
+
"Slime": ["PlatformerMovement", "PatrolAI", "Health", "Hurtbox", "Hitbox"],
|
|
11
|
+
"Coin": ["Collectible"],
|
|
12
|
+
"Door": ["Gate"],
|
|
13
|
+
"Exit": ["TriggerZone"],
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(manifest, *rules):
|
|
18
|
+
d = Diagnostics()
|
|
19
|
+
res = compile_rules([Rule.model_validate(r) for r in rules], ENTITIES, manifest, d)
|
|
20
|
+
return res, d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_short_event_resolves_unique_component(manifest):
|
|
24
|
+
ev = resolve_event("Coin.collected", ENTITIES, manifest)
|
|
25
|
+
assert (ev.component, ev.signal, ev.args) == ("Collectible", "collected", ["by"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_ambiguous_event(manifest):
|
|
29
|
+
with pytest.raises(ExprError) as e:
|
|
30
|
+
resolve_event("Slime.dashed", {"Slime": ["PlatformerMovement", "TopDownMovement"]}, manifest)
|
|
31
|
+
assert "ambiguous" in str(e.value)
|
|
32
|
+
assert "Slime.PlatformerMovement.dashed" in e.value.hint
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_unknown_signal_lists_available(manifest):
|
|
36
|
+
with pytest.raises(ExprError) as e:
|
|
37
|
+
resolve_event("Door.open", ENTITIES, manifest)
|
|
38
|
+
assert "Door.opened" in e.value.hint
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_codegen(manifest):
|
|
42
|
+
res, d = run(manifest,
|
|
43
|
+
{"on": "Coin.collected", "if": "count('Coin') == 0", "do": ["Door.open()", "message('Open!')"]},
|
|
44
|
+
{"on": "Slime.died", "do": ["add_score(5)", "spawn('Coin')"]},
|
|
45
|
+
{"on": "Game.ready", "do": "message('Go')"},
|
|
46
|
+
{"on": "GameState.score_changed", "if": "event.score >= 3", "do": ["win()"]},
|
|
47
|
+
{"on": "Coin.collected", "do": ["event.by.Health.heal(1)"]})
|
|
48
|
+
assert d.ok, [str(x) for x in d]
|
|
49
|
+
src = res.source
|
|
50
|
+
assert res.calls_win
|
|
51
|
+
assert 'World.bind_signal(&"Coin", &"Collectible", &"collected", _rule_0)' in src
|
|
52
|
+
assert "func _rule_0(_a_by, _source) -> void:" in src
|
|
53
|
+
assert 'World.call_all(&"Door", &"Gate", &"open", [])' in src
|
|
54
|
+
assert 'World.spawn.call_deferred("Coin", source.global_position)' in src
|
|
55
|
+
assert "func _rule_1(source) -> void:" in src
|
|
56
|
+
assert "GameState.score_changed.connect(_rule_3)" in src
|
|
57
|
+
assert "func _rule_3(a_score) -> void:" in src
|
|
58
|
+
assert "_on_game_ready.call_deferred()" in src
|
|
59
|
+
assert 'World.call_on(a_by, &"Health", &"heal", [1])' in src
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.parametrize("rule, code, fragment", [
|
|
63
|
+
({"on": "Coin.colected", "do": ["win()"]}, "E_RULE_EVENT", "Did you mean"),
|
|
64
|
+
({"on": "Ghost.died", "do": ["win()"]}, "E_RULE_EVENT", "unknown entity"),
|
|
65
|
+
({"on": "Exit.triggered", "do": ["wn()"]}, "E_RULE_DO", "unknown action"),
|
|
66
|
+
({"on": "Exit.triggered", "do": ["Door.opne()"]}, "E_RULE_DO", "no method"),
|
|
67
|
+
({"on": "Exit.triggered", "do": ["Door.Gate.open(1)"]}, "E_RULE_DO", "argument"),
|
|
68
|
+
({"on": "Exit.triggered", "do": ["Coin.Gate.open()"]}, "E_RULE_DO", "no component"),
|
|
69
|
+
({"on": "Game.ready", "do": ["spawn('Coin')"]}, "E_RULE_DO", "has none"),
|
|
70
|
+
({"on": "Game.ready", "do": ["destroy(self)"]}, "E_RULE_DO", "not available"),
|
|
71
|
+
({"on": "Exit.triggered", "if": "scor > 1", "do": ["win()"]}, "E_RULE_IF", "unknown name"),
|
|
72
|
+
({"on": "Exit.triggered", "do": ["set_var(x, 1)"]}, "E_RULE_DO", "quotes"),
|
|
73
|
+
({"on": "Exit.triggered", "do": ["win"]}, "E_RULE_DO", "must be a call"),
|
|
74
|
+
])
|
|
75
|
+
def test_rule_errors(manifest, rule, code, fragment):
|
|
76
|
+
_, d = run(manifest, rule)
|
|
77
|
+
assert not d.ok
|
|
78
|
+
err = d.errors[0]
|
|
79
|
+
assert err.code == code
|
|
80
|
+
assert fragment in (err.message + " " + (err.hint or ""))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_error_paths_point_at_action(manifest):
|
|
84
|
+
_, d = run(manifest, {"on": "Exit.triggered", "do": ["win()", "nope()"]})
|
|
85
|
+
assert d.errors[0].path == "rules[0].do[1]"
|
tests/test_sfx.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import wave
|
|
2
|
+
|
|
3
|
+
from spawnpoint import sfx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_presets_are_deterministic():
|
|
7
|
+
a = sfx.synthesize(sfx.preset("coin", 3), seed=3)
|
|
8
|
+
b = sfx.synthesize(sfx.preset("coin", 3), seed=3)
|
|
9
|
+
assert a == b
|
|
10
|
+
assert sfx.preset("coin", 3) != sfx.preset("coin", 4)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_all_presets_render_audible_short_sounds(tmp_path):
|
|
14
|
+
files = sfx.write_all(tmp_path, seed=1)
|
|
15
|
+
assert {f.stem for f in files} == set(sfx.PRESETS)
|
|
16
|
+
for f in files:
|
|
17
|
+
with wave.open(str(f)) as w:
|
|
18
|
+
assert w.getframerate() == sfx.SAMPLE_RATE and w.getsampwidth() == 2
|
|
19
|
+
seconds = w.getnframes() / w.getframerate()
|
|
20
|
+
assert 0.02 < seconds <= 2.0, f.name
|
|
21
|
+
frames = w.readframes(w.getnframes())
|
|
22
|
+
peak = max(abs(int.from_bytes(frames[i:i + 2], "little", signed=True)) for i in range(0, len(frames), 2))
|
|
23
|
+
assert peak > 8000, f"{f.name} is too quiet"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_write_all_skips_unchanged(tmp_path):
|
|
27
|
+
sfx.write_all(tmp_path, seed=1, names=["jump"])
|
|
28
|
+
mtime = (tmp_path / "jump.wav").stat().st_mtime_ns
|
|
29
|
+
sfx.write_all(tmp_path, seed=1, names=["jump"])
|
|
30
|
+
assert (tmp_path / "jump.wav").stat().st_mtime_ns == mtime
|
tests/test_sprites.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from spawnpoint.analyze import analyze
|
|
8
|
+
from spawnpoint.sprites import contour, importer
|
|
9
|
+
from spawnpoint.sprites.generate import KINDS, features_for, generate
|
|
10
|
+
from spawnpoint.sprites.resolve import convex_hull, plan_entry, safe_polygon
|
|
11
|
+
from spawnpoint.sprites.spriteset import SpriteError, load
|
|
12
|
+
from spawnpoint.studio.assemble import sprite_kind
|
|
13
|
+
from spawnpoint.studio.brief import GameBrief
|
|
14
|
+
|
|
15
|
+
from conftest import codes, load_example, minimal
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rgba(mask: np.ndarray) -> np.ndarray:
|
|
19
|
+
img = np.zeros((*mask.shape, 4), np.uint8)
|
|
20
|
+
img[mask] = (255, 0, 0, 255)
|
|
21
|
+
return img
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- contour ----------------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def test_contour_of_rectangle_is_exact():
|
|
27
|
+
m = np.zeros((8, 8), bool)
|
|
28
|
+
m[2:6, 1:7] = True
|
|
29
|
+
assert contour.outline(rgba(m)) == [(1.0, 2.0), (7.0, 2.0), (7.0, 6.0), (1.0, 6.0)]
|
|
30
|
+
assert contour.bounds(m) == (1, 2, 6, 4)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_contour_circle_area_and_detached_detail():
|
|
34
|
+
yy, xx = np.mgrid[:20, :20]
|
|
35
|
+
m = ((yy - 10) ** 2 + (xx - 10) ** 2) <= 49
|
|
36
|
+
m[2, 10] = True # antenna tip one pixel above the head
|
|
37
|
+
poly = contour.outline(rgba(m))
|
|
38
|
+
assert abs(abs(contour.polygon_area(poly)) - np.pi * 49) < 15
|
|
39
|
+
assert min(y for _, y in poly) == 2.0 # merged into the outline
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_contour_empty():
|
|
43
|
+
assert contour.outline(np.zeros((4, 4, 4), np.uint8)) == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_safe_polygon_falls_back_to_hull_for_self_intersections():
|
|
47
|
+
bowtie = [(0, 0), (4, 4), (4, 0), (0, 4)]
|
|
48
|
+
assert sorted(safe_polygon(bowtie)) == sorted(convex_hull(bowtie)) == [(0, 0), (0, 4), (4, 0), (4, 4)]
|
|
49
|
+
square = [(0, 0), (4, 0), (4, 4), (0, 4)]
|
|
50
|
+
assert safe_polygon(square) == square
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- generator --------------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("kind", sorted(KINDS))
|
|
56
|
+
def test_every_kind_renders(kind):
|
|
57
|
+
s = generate("Thing", kind, "#ff004d")
|
|
58
|
+
assert "idle" in s.animations
|
|
59
|
+
w, h = s.frame_size
|
|
60
|
+
for name, a in s.animations.items():
|
|
61
|
+
for col, row in a.frames:
|
|
62
|
+
assert (s.frame(col, row)[..., 3] > 0).any(), f"{kind}.{name} frame empty"
|
|
63
|
+
bx, by, bw, bh = s.bounds
|
|
64
|
+
assert 0 <= bx and 0 <= by and bx + bw <= w and by + bh <= h
|
|
65
|
+
assert len(s.polygon) >= 3
|
|
66
|
+
assert generate("Thing", kind, "#ff004d").sheet.tobytes() == s.sheet.tobytes() # deterministic
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_features_from_names():
|
|
70
|
+
assert features_for("NinjaFox") == ["mask", "ears", "tail"]
|
|
71
|
+
assert features_for("fire bat") == ["wings"]
|
|
72
|
+
assert features_for("Player") == []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_names_change_the_look():
|
|
76
|
+
a = generate("Goblin", "hero", "#ff004d").sheet.tobytes()
|
|
77
|
+
b = generate("Ninja", "hero", "#ff004d").sheet.tobytes()
|
|
78
|
+
assert a != b
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- import -----------------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
@pytest.fixture
|
|
84
|
+
def art(tmp_path):
|
|
85
|
+
"""An artist-style sheet (4 columns, 2px spacing) and a folder of frames, from a generated knight."""
|
|
86
|
+
s = generate("Knight", "hero", "#c2c3c7", ["helmet"])
|
|
87
|
+
cells = [s.frame(*f) for a in ("idle", "run") for f in s.animations[a].frames] # 2 idle + 4 run
|
|
88
|
+
sheet = Image.new("RGBA", (4 * 26, 2 * 26))
|
|
89
|
+
for i, f in enumerate(cells):
|
|
90
|
+
sheet.paste(Image.fromarray(f), ((i % 4) * 26 + 6, (i // 4) * 26 + 4))
|
|
91
|
+
sheet.save(tmp_path / "knight.png")
|
|
92
|
+
frames = tmp_path / "frames"
|
|
93
|
+
frames.mkdir()
|
|
94
|
+
for a, fname in (("idle", "idle"), ("run", "walk")):
|
|
95
|
+
for i, f in enumerate(s.animations[a].frames):
|
|
96
|
+
Image.fromarray(s.frame(*f)).save(frames / f"knight_{fname}_{i}.png")
|
|
97
|
+
return tmp_path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_parse_ranges():
|
|
101
|
+
assert importer.parse_ranges("0-3, 7,9-10") == [0, 1, 2, 3, 7, 9, 10]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_import_grid_with_indices(art):
|
|
105
|
+
s = importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"idle": [0, 1], "run": [2, 3, 4, 5]})
|
|
106
|
+
assert s.frame_size == (24, 24)
|
|
107
|
+
assert {k: len(a.frames) for k, a in s.animations.items()} == {"idle": 2, "run": 4}
|
|
108
|
+
assert s.name == "Knight"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_import_grid_rejects_empty_frames(art):
|
|
112
|
+
with pytest.raises(SpriteError, match="empty frame"):
|
|
113
|
+
importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"run": [4, 5, 6, 7]})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_import_grid_rejects_out_of_range(art):
|
|
117
|
+
with pytest.raises(SpriteError, match="has 8 frames"):
|
|
118
|
+
importer.import_sheet(art / "knight.png", grid=(24, 24), spacing=2, anims={"idle": [0, 99]})
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_import_auto_slices_by_gutters(art):
|
|
122
|
+
s = importer.import_sheet(art / "knight.png")
|
|
123
|
+
# Rows become animations in standard order; frames are trimmed and bottom-aligned.
|
|
124
|
+
assert [len(a.frames) for a in s.animations.values()] == [4, 2]
|
|
125
|
+
assert list(s.animations) == ["idle", "run"]
|
|
126
|
+
w, h = s.frame_size
|
|
127
|
+
assert w < 24 and h <= 24
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_import_folder_groups_and_maps_synonyms(art):
|
|
131
|
+
s = importer.import_folder(art / "frames")
|
|
132
|
+
assert {k: len(a.frames) for k, a in s.animations.items()} == {"idle": 2, "run": 4}
|
|
133
|
+
assert s.animations["run"].fps == 10.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_save_load_roundtrip_and_preview(art, tmp_path):
|
|
137
|
+
s = importer.import_folder(art / "frames", "Knight")
|
|
138
|
+
path = s.save(tmp_path / "lib")
|
|
139
|
+
back = load(path)
|
|
140
|
+
assert back.frame_size == s.frame_size and back.polygon == s.polygon and back.bounds == s.bounds
|
|
141
|
+
img = importer.preview(back)
|
|
142
|
+
assert img.width > 100 and img.height > 50
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_load_errors(tmp_path):
|
|
146
|
+
with pytest.raises(SpriteError, match="cannot read"):
|
|
147
|
+
load(tmp_path / "nope.sprite.json")
|
|
148
|
+
Image.new("RGBA", (8, 8), (255, 0, 0, 255)).save(tmp_path / "a.png")
|
|
149
|
+
(tmp_path / "a.sprite.json").write_text(json.dumps({
|
|
150
|
+
"image": "a.png", "frame_size": [8, 8], "animations": {"idle": {"frames": [[1, 0]]}}}))
|
|
151
|
+
with pytest.raises(SpriteError, match="outside"):
|
|
152
|
+
load(tmp_path / "a.sprite.json")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- compiler integration ---------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def test_generated_sprite_derives_collision_and_plan(manifest):
|
|
158
|
+
g = minimal()
|
|
159
|
+
g["entities"]["Player"]["sprite"] = {"generate": {"kind": "hero"}}
|
|
160
|
+
g["entities"]["Coin"]["sprite"] = {"generate": {"kind": "coin"}}
|
|
161
|
+
a = analyze(g, manifest)
|
|
162
|
+
assert a.ok, [str(d) for d in a.diagnostics]
|
|
163
|
+
player = next(e for e in a.plan["entities"] if e["name"] == "Player")
|
|
164
|
+
s = a.sprites["Player"]
|
|
165
|
+
assert player["shape"]["type"] == "capsule"
|
|
166
|
+
assert player["shape"]["size"][1] == s.bounds[3] * s.scale
|
|
167
|
+
assert player["sprite"]["texture"] == "res://sprites/Player.png"
|
|
168
|
+
assert "Animator" in [c["name"] for c in player["components"]]
|
|
169
|
+
assert player["eye"] is False
|
|
170
|
+
# Feet still rest on the floor row.
|
|
171
|
+
p = next(p for p in a.plan["placements"] if p["entity"] == "Player")
|
|
172
|
+
assert p["y"] + player["shape"]["size"][1] / 2 == pytest.approx(64)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_plan_entry_centers_the_opaque_box():
|
|
176
|
+
s = generate("Coin", "coin", "#ffec27")
|
|
177
|
+
e = plan_entry("Coin", s)
|
|
178
|
+
bx, by, bw, bh = s.bounds
|
|
179
|
+
xs = [p[0] for p in e["polygon"]]
|
|
180
|
+
ys = [p[1] for p in e["polygon"]]
|
|
181
|
+
assert min(xs) == pytest.approx(-bw, abs=bw) and max(xs) <= bw * s.scale / 2 + 0.01
|
|
182
|
+
assert min(ys) >= -bh * s.scale / 2 - 0.01 and max(ys) <= bh * s.scale / 2 + 0.01
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_explicit_shape_wins(manifest):
|
|
186
|
+
g = minimal()
|
|
187
|
+
g["entities"]["Player"]["sprite"] = {"generate": {"kind": "hero"}}
|
|
188
|
+
g["entities"]["Player"]["shape"] = {"type": "rect", "size": [20, 20]}
|
|
189
|
+
plan = analyze(g, manifest).plan
|
|
190
|
+
player = next(e for e in plan["entities"] if e["name"] == "Player")
|
|
191
|
+
assert player["shape"] == {"type": "rect", "size": [20.0, 20.0], "color": "#29adff"}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.parametrize("sprite, fragment", [
|
|
195
|
+
({"generate": {"kind": "dragon"}}, "unknown sprite kind"),
|
|
196
|
+
("art/hero.sprite.json", "needs the graph's folder"),
|
|
197
|
+
])
|
|
198
|
+
def test_sprite_errors(manifest, sprite, fragment):
|
|
199
|
+
g = minimal()
|
|
200
|
+
g["entities"]["Player"]["sprite"] = sprite
|
|
201
|
+
a = analyze(g, manifest)
|
|
202
|
+
err = next(d for d in a.diagnostics.errors if d.code == "E_SPRITE")
|
|
203
|
+
assert fragment in err.message and err.path == "entities.Player.sprite"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_sprite_path_relative_to_graph(manifest, tmp_path):
|
|
207
|
+
generate("Hero", "hero", "#29adff").save(tmp_path / "art", "hero")
|
|
208
|
+
g = minimal()
|
|
209
|
+
g["entities"]["Player"]["sprite"] = "art/hero.sprite.json"
|
|
210
|
+
assert analyze(g, manifest, base_dir=tmp_path).ok
|
|
211
|
+
g["entities"]["Player"]["sprite"] = "art/missing.sprite.json"
|
|
212
|
+
assert "E_SPRITE" in codes(analyze(g, manifest, base_dir=tmp_path).diagnostics)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_pixel_example_is_clean(manifest):
|
|
216
|
+
a = analyze(load_example("pixel_hopper.json"), manifest)
|
|
217
|
+
assert a.ok and not a.diagnostics.warnings, [str(d) for d in a.diagnostics]
|
|
218
|
+
assert len(a.sprites) == 8
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- studio kind mapping ----------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
@pytest.mark.parametrize("name, comps, archetype, platformer, kind", [
|
|
224
|
+
("Player", {}, None, True, "hero"),
|
|
225
|
+
("Goblin", {}, "walker", True, "hero"),
|
|
226
|
+
("Slime", {}, "walker", True, "slime"),
|
|
227
|
+
("FireBat", {}, "chaser", True, "flyer"),
|
|
228
|
+
("LaserBot", {}, "shooter", False, "robot"),
|
|
229
|
+
("Beetle", {}, "chaser", False, "bug"),
|
|
230
|
+
("Thing", {}, "turret", False, "turret"),
|
|
231
|
+
("Star", {"Collectible": {}}, None, True, "star"),
|
|
232
|
+
("Acorn", {"Collectible": {}}, None, True, "orb"),
|
|
233
|
+
("Crystal", {"Collectible": {}}, None, True, "gem"),
|
|
234
|
+
("Exit", {}, None, False, "portal"),
|
|
235
|
+
])
|
|
236
|
+
def test_sprite_kind(name, comps, archetype, platformer, kind):
|
|
237
|
+
b = GameBrief(title="t", genre="platformer" if platformer else "topdown")
|
|
238
|
+
assert sprite_kind(name, {"components": comps}, archetype, b, platformer) == kind
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- tiles & zoom -----------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize("genre", ["platformer", "topdown"])
|
|
244
|
+
def test_pixel_tileset(genre):
|
|
245
|
+
from spawnpoint.sprites.tiles import tileset
|
|
246
|
+
img = tileset(genre, "#5f574f", "#00e436", "#ab5236", 32, seed=3)
|
|
247
|
+
assert img.size == (96, 32)
|
|
248
|
+
px = np.asarray(img)
|
|
249
|
+
assert (px[:, :64, 3] == 255).all() # solid tiles are fully opaque
|
|
250
|
+
assert (px[20:, 64:, 3] == 0).all() # the one-way plank leaves the lower part open
|
|
251
|
+
assert tileset(genre, "#5f574f", "#00e436", "#ab5236", 32, seed=3).tobytes() == img.tobytes()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_plan_carries_tiles_and_zoom(manifest):
|
|
255
|
+
g = minimal(settings={"zoom": 1.5})
|
|
256
|
+
a = analyze(g, manifest)
|
|
257
|
+
assert a.plan["zoom"] == 1.5 and a.plan["tiles"]["texture"] == "res://sprites/tiles.png"
|
|
258
|
+
assert a.tiles_image is not None
|
|
259
|
+
flat = analyze(minimal(settings={"tile_style": "flat"}), manifest)
|
|
260
|
+
assert "texture" not in flat.plan["tiles"] and flat.tiles_image is None
|
|
261
|
+
assert "E_SCHEMA" in codes(analyze(minimal(settings={"zoom": 9}), manifest).diagnostics)
|