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
spawnpoint/rules.py ADDED
@@ -0,0 +1,310 @@
1
+ """Declarative rules -> generated GDScript (`rules.gd`).
2
+
3
+ A rule is {"on": <event>, "if": <expr>?, "do": [<action>...]}. Every reference is checked against
4
+ the graph and the component manifest, so the generated script is valid by construction.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from dataclasses import dataclass
11
+
12
+ from .diagnostics import Diagnostics, did_you_mean, join_path
13
+ from .expr import ExprError, Scope, _attr_chain, entity_literal, gd_string, normalize, translate
14
+ from .graph import Rule
15
+ from .manifest import Manifest
16
+
17
+ GAMESTATE_EVENTS: dict[str, list[str]] = {
18
+ "score_changed": ["score"],
19
+ "lives_changed": ["lives"],
20
+ "var_changed": ["name", "value"],
21
+ "second_passed": ["seconds"],
22
+ "game_won": [],
23
+ "game_lost": [],
24
+ }
25
+
26
+ # name -> (min args, max args, description)
27
+ BUILTINS: dict[str, tuple[int, int, str]] = {
28
+ "add_score": (1, 1, "add_score(n)"),
29
+ "set_var": (2, 2, 'set_var("name", value)'),
30
+ "add_var": (2, 2, 'add_var("name", delta)'),
31
+ "win": (0, 0, "win()"),
32
+ "lose": (0, 0, "lose()"),
33
+ "restart": (0, 0, "restart()"),
34
+ "spawn": (1, 1, 'spawn("Entity") at the emitting entity'),
35
+ "spawn_at": (3, 3, 'spawn_at("Entity", tile_x, tile_y)'),
36
+ "destroy": (1, 1, 'destroy(self) | destroy("Entity") | destroy(event.arg)'),
37
+ "shake": (1, 1, "shake(0..1)"),
38
+ "freeze": (1, 1, "freeze(seconds)"),
39
+ "sound": (1, 1, 'sound("name")'),
40
+ "message": (1, 1, 'message("text")'),
41
+ }
42
+
43
+ HEADER = """\
44
+ # Generated by Spawn Point from the game graph (spawnpoint.json).
45
+ # Do not edit by hand: change the graph's "rules" and rebuild.
46
+ extends Node"""
47
+
48
+
49
+ @dataclass
50
+ class Event:
51
+ kind: str # "entity" | "gamestate" | "ready"
52
+ label: str
53
+ args: list[str]
54
+ entity: str | None = None
55
+ component: str | None = None
56
+ signal: str | None = None
57
+
58
+
59
+ @dataclass
60
+ class RulesResult:
61
+ source: str
62
+ calls_win: bool
63
+
64
+
65
+ def resolve_event(on: str, entities: dict[str, list[str]], manifest: Manifest) -> Event:
66
+ parts = on.strip().split(".")
67
+ if parts == ["Game", "ready"]:
68
+ return Event("ready", on, [])
69
+ if parts[0] == "GameState":
70
+ if len(parts) != 2 or parts[1] not in GAMESTATE_EVENTS:
71
+ raise ExprError(f"unknown GameState event '{on}'",
72
+ did_you_mean(parts[-1], GAMESTATE_EVENTS))
73
+ return Event("gamestate", on, GAMESTATE_EVENTS[parts[1]], signal=parts[1])
74
+ ent = parts[0]
75
+ if ent not in entities:
76
+ raise ExprError(f"unknown entity '{ent}' in event '{on}'",
77
+ did_you_mean(ent, [*entities, "GameState", "Game"]))
78
+ comps = entities[ent]
79
+ if len(parts) == 3:
80
+ comp_name, sig = parts[1], parts[2]
81
+ if comp_name not in comps:
82
+ raise ExprError(f"{ent} has no component '{comp_name}'", did_you_mean(comp_name, comps))
83
+ comp = manifest.get(comp_name)
84
+ if comp is None or sig not in comp.signals:
85
+ raise ExprError(f"{comp_name} has no signal '{sig}'", did_you_mean(sig, comp.signals if comp else []))
86
+ elif len(parts) == 2:
87
+ sig = parts[1]
88
+ owners = [c for c in comps if (m := manifest.get(c)) and sig in m.signals]
89
+ if not owners:
90
+ available = [f"{ent}.{s}" for c in comps if (m := manifest.get(c)) for s in m.signals]
91
+ raise ExprError(f"no component of {ent} emits '{sig}'",
92
+ did_you_mean(f"{ent}.{sig}", available) if available else f"{ent} has no signals")
93
+ if len(owners) > 1:
94
+ raise ExprError(f"'{on}' is ambiguous", "Use one of: " + ", ".join(f"{ent}.{o}.{sig}" for o in owners))
95
+ comp_name = owners[0]
96
+ else:
97
+ raise ExprError(f"cannot parse event '{on}'", "Use Entity.signal, Entity.Component.signal, GameState.<event> or Game.ready")
98
+ sig_def = manifest.get(comp_name).signals[sig]
99
+ return Event("entity", on, [a.name for a in sig_def.args], ent, comp_name, sig)
100
+
101
+
102
+ class _Actions:
103
+ """Translates action calls of one rule into GDScript statements."""
104
+
105
+ def __init__(self, scope: Scope, entities: dict[str, list[str]], manifest: Manifest):
106
+ self.scope = scope
107
+ self.entities = entities
108
+ self.manifest = manifest
109
+ self.calls_win = False
110
+
111
+ def expr(self, node: ast.AST) -> str:
112
+ return translate(ast.unparse(node), self.scope)
113
+
114
+ def translate(self, src: str) -> str:
115
+ try:
116
+ node = ast.parse(normalize(src).strip(), mode="eval").body
117
+ except SyntaxError as e:
118
+ raise ExprError(f"syntax error in action '{src}': {e.msg}") from e
119
+ if not isinstance(node, ast.Call):
120
+ raise ExprError(f"action '{src}' must be a call like win() or Door.open()")
121
+ if node.keywords:
122
+ raise ExprError("keyword arguments are not supported in actions")
123
+ if isinstance(node.func, ast.Name):
124
+ return self.builtin(node.func.id, node.args)
125
+ chain = _attr_chain(node.func)
126
+ if chain is None:
127
+ raise ExprError(f"cannot call '{ast.unparse(node.func)}'")
128
+ return self.method_call(chain, node.args, src)
129
+
130
+ def builtin(self, name: str, args: list[ast.expr]) -> str:
131
+ if name not in BUILTINS:
132
+ raise ExprError(f"unknown action '{name}()'", did_you_mean(name, BUILTINS))
133
+ lo, hi, usage = BUILTINS[name]
134
+ if not lo <= len(args) <= hi:
135
+ raise ExprError(f"wrong number of arguments for {name}()", f"Usage: {usage}")
136
+ e = self.expr
137
+ match name:
138
+ case "add_score":
139
+ return f"GameState.add_score(int({e(args[0])}))"
140
+ case "set_var" | "add_var":
141
+ key = _string_literal(args[0], f"{name}() needs the variable name in quotes")
142
+ return f"GameState.{name}({gd_string(key)}, {e(args[1])})"
143
+ case "win":
144
+ self.calls_win = True
145
+ return "GameState.win()"
146
+ case "lose":
147
+ return "GameState.lose()"
148
+ case "restart":
149
+ return "GameState.restart.call_deferred()"
150
+ case "spawn":
151
+ ent = entity_literal(args[0], self.entities)
152
+ if self.scope.self_entity is None:
153
+ raise ExprError("spawn() places the entity at the emitting entity, but this event has none",
154
+ f'Use spawn_at("{ent}", tile_x, tile_y)')
155
+ self.scope.used.add("source")
156
+ return f'World.spawn.call_deferred({gd_string(ent)}, source.global_position)'
157
+ case "spawn_at":
158
+ ent = entity_literal(args[0], self.entities)
159
+ return f"World.spawn_at_tile.call_deferred({gd_string(ent)}, int({e(args[1])}), int({e(args[2])}))"
160
+ case "destroy":
161
+ a = args[0]
162
+ if isinstance(a, ast.Name) and a.id == "self":
163
+ return f"World.destroy({e(a)})"
164
+ if isinstance(a, ast.Attribute):
165
+ return f"World.destroy({e(a)})"
166
+ ent = entity_literal(a, self.entities)
167
+ return f'World.destroy_all(&"{ent}")'
168
+ case "shake":
169
+ return f"FX.shake({e(args[0])})"
170
+ case "freeze":
171
+ return f"FX.hitstop({e(args[0])})"
172
+ case "sound":
173
+ snd = _string_literal(args[0], 'sound() needs a sound name in quotes, e.g. sound("coin")')
174
+ return f"Sfx.play({gd_string(snd)})"
175
+ case "message":
176
+ return f"GameState.message(str({e(args[0])}))"
177
+ raise AssertionError(name)
178
+
179
+ def method_call(self, chain: list[str], args: list[ast.expr], src: str) -> str:
180
+ head = chain[0]
181
+ if head == "event":
182
+ if len(chain) < 3:
183
+ raise ExprError(f"cannot call '{'.'.join(chain)}'", "Use event.<arg>.<Component>.<method>()")
184
+ target_code = self.expr(ast.parse(f"event.{chain[1]}", mode="eval").body)
185
+ rest = chain[2:]
186
+ ent_type = None
187
+ elif head == "self":
188
+ if self.scope.self_entity is None:
189
+ raise ExprError("'self' is not available for this event (no emitting entity)")
190
+ self.scope.used.add("source")
191
+ target_code = "source"
192
+ rest = chain[1:]
193
+ ent_type = self.scope.self_entity
194
+ elif head in self.entities:
195
+ target_code = None
196
+ rest = chain[1:]
197
+ ent_type = head
198
+ else:
199
+ raise ExprError(f"unknown call target '{head}' in '{src}'",
200
+ did_you_mean(head, [*self.entities, "self", "event"]))
201
+
202
+ if len(rest) == 2:
203
+ comp_name, method = rest
204
+ elif len(rest) == 1:
205
+ method = rest[0]
206
+ comp_name = self._component_for(method, ent_type)
207
+ else:
208
+ raise ExprError(f"cannot call '{'.'.join(chain)}'", "Use Entity.method() or Entity.Component.method()")
209
+
210
+ comp = self.manifest.get(comp_name)
211
+ if comp is None:
212
+ raise ExprError(f"unknown component '{comp_name}'", did_you_mean(comp_name, self.manifest.components))
213
+ if ent_type is not None and comp_name not in self.entities[ent_type]:
214
+ raise ExprError(f"{ent_type} has no component '{comp_name}'", did_you_mean(comp_name, self.entities[ent_type]))
215
+ m = comp.methods.get(method)
216
+ if m is None:
217
+ raise ExprError(f"{comp_name} has no method '{method}'", did_you_mean(method, comp.methods))
218
+ if not m.min_args <= len(args) <= len(m.args):
219
+ sig = ", ".join(a.name for a in m.args)
220
+ raise ExprError(f"{comp_name}.{method}() takes {m.min_args}-{len(m.args)} argument(s), got {len(args)}",
221
+ f"Signature: {method}({sig})")
222
+ arg_code = "[" + ", ".join(self.expr(a) for a in args) + "]"
223
+ if target_code is None:
224
+ return f'World.call_all(&"{ent_type}", &"{comp_name}", &"{method}", {arg_code})'
225
+ return f'World.call_on({target_code}, &"{comp_name}", &"{method}", {arg_code})'
226
+
227
+ def _component_for(self, method: str, ent_type: str | None) -> str:
228
+ candidates = self.entities[ent_type] if ent_type else list(self.manifest.components)
229
+ owners = [c for c in candidates if (m := self.manifest.get(c)) and method in m.methods]
230
+ if not owners:
231
+ available = sorted({mm for c in candidates if (m := self.manifest.get(c)) for mm in m.methods})
232
+ where = ent_type or "any component"
233
+ raise ExprError(f"{where} has no method '{method}'", did_you_mean(method, available))
234
+ if len(owners) > 1:
235
+ raise ExprError(f"method '{method}' is ambiguous", "Name the component: " + ", ".join(f"{o}.{method}()" for o in owners))
236
+ return owners[0]
237
+
238
+
239
+ def _string_literal(node: ast.AST, message: str) -> str:
240
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
241
+ return node.value
242
+ raise ExprError(message)
243
+
244
+
245
+ def compile_rules(rules: list[Rule], entities: dict[str, list[str]], manifest: Manifest,
246
+ diags: Diagnostics) -> RulesResult:
247
+ binds: list[str] = []
248
+ ready_calls: list[str] = []
249
+ funcs: list[str] = []
250
+ calls_win = False
251
+ for i, rule in enumerate(rules):
252
+ path = join_path("rules", i)
253
+ try:
254
+ ev = resolve_event(rule.on, entities, manifest)
255
+ except ExprError as e:
256
+ diags.error("E_RULE_EVENT", path + ".on", str(e), e.hint)
257
+ continue
258
+ arg_names = {a: f"a_{a}" for a in ev.args}
259
+ scope = Scope(manifest, entities, arg_names, ev.entity if ev.kind == "entity" else None)
260
+ ok = True
261
+ cond = None
262
+ if rule.if_:
263
+ try:
264
+ cond = translate(rule.if_, scope)
265
+ except ExprError as e:
266
+ diags.error("E_RULE_IF", path + ".if", str(e), e.hint)
267
+ ok = False
268
+ actions = _Actions(scope, entities, manifest)
269
+ body: list[str] = []
270
+ if not rule.actions:
271
+ diags.error("E_RULE_DO", path + ".do", "rule has no actions")
272
+ ok = False
273
+ for j, act in enumerate(rule.actions):
274
+ try:
275
+ body.append(actions.translate(act))
276
+ except ExprError as e:
277
+ diags.error("E_RULE_DO", join_path(path, "do", j), str(e), e.hint)
278
+ ok = False
279
+ if not ok:
280
+ continue
281
+ calls_win |= actions.calls_win
282
+
283
+ fname = f"_rule_{i}"
284
+ params = [(gd if gd in scope.used else "_" + gd) for gd in arg_names.values()]
285
+ if ev.kind == "entity":
286
+ params.append("source" if "source" in scope.used else "_source")
287
+ binds.append(f'World.bind_signal(&"{ev.entity}", &"{ev.component}", &"{ev.signal}", {fname})')
288
+ elif ev.kind == "gamestate":
289
+ binds.append(f"GameState.{ev.signal}.connect({fname})")
290
+ else:
291
+ ready_calls.append(f"{fname}()")
292
+
293
+ comment = f"# Rule {i}: on {rule.on}" + (f" if {rule.if_}" if rule.if_ else "")
294
+ lines = [comment, f"func {fname}({', '.join(params)}) -> void:"]
295
+ if cond:
296
+ lines += [f"\tif not {cond}:", "\t\treturn"]
297
+ lines += [f"\t{stmt}" for stmt in body]
298
+ funcs.append("\n".join(lines))
299
+
300
+ ready = ["func _ready() -> void:"]
301
+ ready += [f"\t{b}" for b in binds]
302
+ if ready_calls:
303
+ ready.append("\t_on_game_ready.call_deferred()")
304
+ if len(ready) == 1:
305
+ ready.append("\tpass")
306
+ chunks = [HEADER, "\n".join(ready)]
307
+ if ready_calls:
308
+ chunks.append("\n".join(["func _on_game_ready() -> void:", *[f"\t{c}" for c in ready_calls]]))
309
+ chunks += funcs
310
+ return RulesResult("\n\n\n".join(chunks) + "\n", calls_win)
spawnpoint/sfx.py ADDED
@@ -0,0 +1,352 @@
1
+ """Procedural retro sound effects (a port of DrPetter's sfxr synth and its preset generators).
2
+
3
+ Every game gets jump / land / hurt / shoot / coin / explosion / powerup / dash / blip sounds,
4
+ deterministic for a given seed, so the first build already has audio feedback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ import math
11
+ import random
12
+ import struct
13
+ import wave
14
+ from dataclasses import dataclass, replace
15
+ from pathlib import Path
16
+
17
+ SAMPLE_RATE = 44100
18
+ SQUARE, SAW, SINE, NOISE = 0, 1, 2, 3
19
+
20
+
21
+ @dataclass
22
+ class SfxParams:
23
+ wave_type: int = SQUARE
24
+ base_freq: float = 0.3
25
+ freq_limit: float = 0.0
26
+ freq_ramp: float = 0.0
27
+ freq_dramp: float = 0.0
28
+ duty: float = 0.0
29
+ duty_ramp: float = 0.0
30
+ vib_strength: float = 0.0
31
+ vib_speed: float = 0.0
32
+ env_attack: float = 0.0
33
+ env_sustain: float = 0.3
34
+ env_decay: float = 0.4
35
+ env_punch: float = 0.0
36
+ lpf_resonance: float = 0.0
37
+ lpf_freq: float = 1.0
38
+ lpf_ramp: float = 0.0
39
+ hpf_freq: float = 0.0
40
+ hpf_ramp: float = 0.0
41
+ pha_offset: float = 0.0
42
+ pha_ramp: float = 0.0
43
+ repeat_speed: float = 0.0
44
+ arp_speed: float = 0.0
45
+ arp_mod: float = 0.0
46
+ volume: float = 0.6
47
+
48
+
49
+ # --- preset generators (after sfxr's "generator" buttons) -------------------------------------
50
+
51
+
52
+ def _pickup(r: random.Random) -> SfxParams:
53
+ p = SfxParams(base_freq=0.4 + r.random() * 0.5, env_sustain=r.random() * 0.1,
54
+ env_decay=0.1 + r.random() * 0.4, env_punch=0.3 + r.random() * 0.3)
55
+ p.arp_speed = 0.5 + r.random() * 0.2
56
+ p.arp_mod = 0.2 + r.random() * 0.4
57
+ return p
58
+
59
+
60
+ def _laser(r: random.Random) -> SfxParams:
61
+ p = SfxParams(wave_type=r.choice([SQUARE, SAW, SINE]))
62
+ p.base_freq = 0.5 + r.random() * 0.5
63
+ p.freq_limit = max(0.2, p.base_freq - 0.2 - r.random() * 0.6)
64
+ p.freq_ramp = -0.15 - r.random() * 0.2
65
+ if r.random() < 0.5:
66
+ p.duty, p.duty_ramp = r.random() * 0.5, r.random() * 0.2
67
+ else:
68
+ p.duty, p.duty_ramp = 0.4 + r.random() * 0.5, -r.random() * 0.7
69
+ p.env_sustain = 0.1 + r.random() * 0.2
70
+ p.env_decay = r.random() * 0.4
71
+ if r.random() < 0.5:
72
+ p.env_punch = r.random() * 0.3
73
+ if r.random() < 0.33:
74
+ p.pha_offset, p.pha_ramp = r.random() * 0.2, -r.random() * 0.2
75
+ p.hpf_freq = r.random() * 0.3 if r.random() < 0.5 else 0.0
76
+ p.volume = 0.45
77
+ return p
78
+
79
+
80
+ def _explosion(r: random.Random) -> SfxParams:
81
+ p = SfxParams(wave_type=NOISE)
82
+ if r.random() < 0.5:
83
+ p.base_freq, p.freq_ramp = 0.1 + r.random() * 0.4, -0.1 + r.random() * 0.4
84
+ else:
85
+ p.base_freq, p.freq_ramp = 0.2 + r.random() * 0.7, -0.2 - r.random() * 0.2
86
+ p.base_freq *= p.base_freq
87
+ if r.random() < 0.2:
88
+ p.freq_ramp = 0.0
89
+ if r.random() < 0.33:
90
+ p.repeat_speed = 0.3 + r.random() * 0.5
91
+ p.env_sustain = 0.1 + r.random() * 0.3
92
+ p.env_decay = 0.2 + r.random() * 0.4
93
+ if r.random() < 0.5:
94
+ p.pha_offset, p.pha_ramp = -0.3 + r.random() * 0.9, -r.random() * 0.3
95
+ p.env_punch = 0.2 + r.random() * 0.6
96
+ if r.random() < 0.5:
97
+ p.vib_strength, p.vib_speed = r.random() * 0.7, r.random() * 0.6
98
+ return p
99
+
100
+
101
+ def _powerup(r: random.Random) -> SfxParams:
102
+ p = SfxParams()
103
+ if r.random() < 0.5:
104
+ p.wave_type = SAW
105
+ else:
106
+ p.duty = r.random() * 0.6
107
+ if r.random() < 0.5:
108
+ p.base_freq, p.freq_ramp, p.repeat_speed = 0.2 + r.random() * 0.3, 0.1 + r.random() * 0.4, 0.4 + r.random() * 0.4
109
+ else:
110
+ p.base_freq, p.freq_ramp = 0.2 + r.random() * 0.3, 0.05 + r.random() * 0.2
111
+ if r.random() < 0.5:
112
+ p.vib_strength, p.vib_speed = r.random() * 0.7, r.random() * 0.6
113
+ p.env_sustain = r.random() * 0.4
114
+ p.env_decay = 0.1 + r.random() * 0.4
115
+ return p
116
+
117
+
118
+ def _hurt(r: random.Random) -> SfxParams:
119
+ p = SfxParams(wave_type=r.choice([SQUARE, SAW, NOISE]))
120
+ if p.wave_type == SQUARE:
121
+ p.duty = r.random() * 0.6
122
+ p.base_freq = 0.2 + r.random() * 0.6
123
+ p.freq_ramp = -0.3 - r.random() * 0.4
124
+ p.env_sustain = r.random() * 0.1
125
+ p.env_decay = 0.1 + r.random() * 0.2
126
+ if r.random() < 0.5:
127
+ p.hpf_freq = r.random() * 0.3
128
+ return p
129
+
130
+
131
+ def _jump(r: random.Random) -> SfxParams:
132
+ p = SfxParams(wave_type=SQUARE, duty=r.random() * 0.6)
133
+ p.base_freq = 0.3 + r.random() * 0.3
134
+ p.freq_ramp = 0.1 + r.random() * 0.2
135
+ p.env_sustain = 0.1 + r.random() * 0.3
136
+ p.env_decay = 0.1 + r.random() * 0.2
137
+ if r.random() < 0.5:
138
+ p.hpf_freq = r.random() * 0.3
139
+ if r.random() < 0.5:
140
+ p.lpf_freq = 1.0 - r.random() * 0.6
141
+ p.volume = 0.45
142
+ return p
143
+
144
+
145
+ def _blip(r: random.Random) -> SfxParams:
146
+ p = SfxParams(wave_type=r.choice([SQUARE, SAW]))
147
+ if p.wave_type == SQUARE:
148
+ p.duty = r.random() * 0.6
149
+ p.base_freq = 0.2 + r.random() * 0.4
150
+ p.env_sustain = 0.1 + r.random() * 0.1
151
+ p.env_decay = r.random() * 0.2
152
+ p.hpf_freq = 0.1
153
+ return p
154
+
155
+
156
+ def _land(r: random.Random) -> SfxParams:
157
+ # A soft, low thud.
158
+ return SfxParams(wave_type=NOISE, base_freq=0.08 + r.random() * 0.04, freq_ramp=-0.1,
159
+ env_sustain=0.02, env_decay=0.12 + r.random() * 0.05, env_punch=0.4,
160
+ lpf_freq=0.35, volume=0.5)
161
+
162
+
163
+ def _dash(r: random.Random) -> SfxParams:
164
+ # A breathy whoosh.
165
+ return SfxParams(wave_type=NOISE, base_freq=0.35 + r.random() * 0.1, freq_ramp=-0.25,
166
+ env_attack=0.05, env_sustain=0.08, env_decay=0.18, lpf_freq=0.5, lpf_ramp=-0.2,
167
+ hpf_freq=0.15, volume=0.45)
168
+
169
+
170
+ PRESETS = {
171
+ "jump": _jump,
172
+ "land": _land,
173
+ "hurt": _hurt,
174
+ "shoot": _laser,
175
+ "coin": _pickup,
176
+ "explosion": _explosion,
177
+ "powerup": _powerup,
178
+ "dash": _dash,
179
+ "blip": _blip,
180
+ }
181
+
182
+
183
+ def preset(name: str, seed: int = 0) -> SfxParams:
184
+ # Stable per-preset seed so changing one preset never changes the others.
185
+ r = random.Random(f"{name}:{seed}")
186
+ return PRESETS[name](r)
187
+
188
+
189
+ # --- synthesis --------------------------------------------------------------------------------
190
+
191
+
192
+ def synthesize(p: SfxParams, seed: int = 0, max_seconds: float = 2.0) -> list[float]:
193
+ rnd = random.Random(seed)
194
+ out: list[float] = []
195
+
196
+ def reset(restart: bool) -> None:
197
+ nonlocal fperiod, fmaxperiod, fslide, fdslide, period, square_duty, square_slide
198
+ nonlocal arp_mod, arp_time, arp_limit
199
+ fperiod = 100.0 / (p.base_freq * p.base_freq + 0.001)
200
+ period = int(fperiod)
201
+ fmaxperiod = 100.0 / (p.freq_limit * p.freq_limit + 0.001)
202
+ fslide = 1.0 - p.freq_ramp**3 * 0.01
203
+ fdslide = -(p.freq_dramp**3) * 0.000001
204
+ square_duty = 0.5 - p.duty * 0.5
205
+ square_slide = -p.duty_ramp * 0.00005
206
+ arp_mod = 1.0 - p.arp_mod**2 * 0.9 if p.arp_mod >= 0 else 1.0 + p.arp_mod**2 * 10.0
207
+ arp_time = 0
208
+ arp_limit = int((1.0 - p.arp_speed) ** 2 * 20000 + 32)
209
+ if p.arp_speed == 1.0:
210
+ arp_limit = 0
211
+
212
+ fperiod = fmaxperiod = fslide = fdslide = square_duty = square_slide = arp_mod = 0.0
213
+ period = arp_time = arp_limit = 0
214
+ reset(False)
215
+
216
+ phase = 0
217
+ fltp = fltdp = fltphp = 0.0
218
+ fltw = p.lpf_freq**3 * 0.1
219
+ fltw_d = 1.0 + p.lpf_ramp * 0.0001
220
+ fltdmp = min(5.0 / (1.0 + p.lpf_resonance**2 * 20.0) * (0.01 + fltw), 0.8)
221
+ flthp = p.hpf_freq**2 * 0.1
222
+ flthp_d = 1.0 + p.hpf_ramp * 0.0003
223
+ vib_phase = 0.0
224
+ vib_speed = p.vib_speed**2 * 0.01
225
+ vib_amp = p.vib_strength * 0.5
226
+ env_vol = 0.0
227
+ env_stage = 0
228
+ env_time = 0
229
+ env_length = [max(1, int(p.env_attack**2 * 100000)), max(1, int(p.env_sustain**2 * 100000)),
230
+ max(1, int(p.env_decay**2 * 100000))]
231
+ fphase = p.pha_offset**2 * 1020.0 * (1 if p.pha_offset >= 0 else -1)
232
+ fdphase = p.pha_ramp**2 * (1 if p.pha_ramp >= 0 else -1)
233
+ iphase = abs(int(fphase))
234
+ ipp = 0
235
+ phaser = [0.0] * 1024
236
+ noise = [rnd.uniform(-1, 1) for _ in range(32)]
237
+ rep_time = 0
238
+ rep_limit = int((1.0 - p.repeat_speed) ** 2 * 20000 + 32)
239
+ if p.repeat_speed == 0.0:
240
+ rep_limit = 0
241
+
242
+ for _ in range(int(SAMPLE_RATE * max_seconds)):
243
+ rep_time += 1
244
+ if rep_limit and rep_time >= rep_limit:
245
+ rep_time = 0
246
+ reset(True)
247
+ arp_time += 1
248
+ if arp_limit and arp_time >= arp_limit:
249
+ arp_limit = 0
250
+ fperiod *= arp_mod
251
+ fslide += fdslide
252
+ fperiod *= fslide
253
+ if fperiod > fmaxperiod:
254
+ fperiod = fmaxperiod
255
+ if p.freq_limit > 0.0:
256
+ break
257
+ rfperiod = fperiod
258
+ if vib_amp > 0.0:
259
+ vib_phase += vib_speed
260
+ rfperiod = fperiod * (1.0 + math.sin(vib_phase) * vib_amp)
261
+ period = max(int(rfperiod), 8)
262
+ square_duty = min(max(square_duty + square_slide, 0.0), 0.5)
263
+
264
+ env_time += 1
265
+ if env_time > env_length[env_stage]:
266
+ env_time = 0
267
+ env_stage += 1
268
+ if env_stage == 3:
269
+ break
270
+ if env_stage == 0:
271
+ env_vol = env_time / env_length[0]
272
+ elif env_stage == 1:
273
+ env_vol = 1.0 + (1.0 - env_time / env_length[1]) * 2.0 * p.env_punch
274
+ else:
275
+ env_vol = 1.0 - env_time / env_length[2]
276
+
277
+ fphase += fdphase
278
+ iphase = min(abs(int(fphase)), 1023)
279
+ if flthp_d != 1.0:
280
+ flthp = min(max(flthp * flthp_d, 0.00001), 0.1)
281
+
282
+ ssample = 0.0
283
+ for _s in range(8): # 8x supersampling
284
+ phase += 1
285
+ if phase >= period:
286
+ phase %= period
287
+ if p.wave_type == NOISE:
288
+ noise = [rnd.uniform(-1, 1) for _ in range(32)]
289
+ fp = phase / period
290
+ if p.wave_type == SQUARE:
291
+ sample = 0.5 if fp < square_duty else -0.5
292
+ elif p.wave_type == SAW:
293
+ sample = 1.0 - fp * 2.0
294
+ elif p.wave_type == SINE:
295
+ sample = math.sin(fp * 2.0 * math.pi)
296
+ else:
297
+ sample = noise[phase * 32 // period]
298
+ pp = fltp
299
+ fltw = min(max(fltw * fltw_d, 0.0), 0.1)
300
+ if p.lpf_freq != 1.0:
301
+ fltdp += (sample - fltp) * fltw
302
+ fltdp -= fltdp * fltdmp
303
+ else:
304
+ fltp = sample
305
+ fltdp = 0.0
306
+ fltp += fltdp
307
+ fltphp += fltp - pp
308
+ fltphp -= fltphp * flthp
309
+ sample = fltphp
310
+ phaser[ipp & 1023] = sample
311
+ sample += phaser[(ipp - iphase + 1024) & 1023]
312
+ ipp = (ipp + 1) & 1023
313
+ ssample += sample * env_vol
314
+ out.append(ssample / 8.0)
315
+
316
+ # Normalize to the requested loudness and fade the last few ms to avoid clicks.
317
+ peak = max((abs(s) for s in out), default=0.0)
318
+ if peak > 0:
319
+ k = p.volume / peak
320
+ out = [s * k for s in out]
321
+ fade = min(len(out), int(SAMPLE_RATE * 0.005))
322
+ for i in range(fade):
323
+ out[-1 - i] *= i / fade
324
+ return out
325
+
326
+
327
+ def to_wav_bytes(samples: list[float]) -> bytes:
328
+ buf = io.BytesIO()
329
+ with wave.open(buf, "wb") as w:
330
+ w.setnchannels(1)
331
+ w.setsampwidth(2)
332
+ w.setframerate(SAMPLE_RATE)
333
+ w.writeframes(b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767)) for s in samples))
334
+ return buf.getvalue()
335
+
336
+
337
+ def write_all(out_dir: Path, seed: int = 0, names: list[str] | None = None) -> list[Path]:
338
+ """Writes <name>.wav for each preset. Skips files whose params did not change."""
339
+ out_dir.mkdir(parents=True, exist_ok=True)
340
+ written = []
341
+ for name in names or list(PRESETS):
342
+ params = preset(name, seed)
343
+ stamp = out_dir / f".{name}.params"
344
+ target = out_dir / f"{name}.wav"
345
+ key = repr(replace(params))
346
+ if target.exists() and stamp.exists() and stamp.read_text() == key:
347
+ written.append(target)
348
+ continue
349
+ target.write_bytes(to_wav_bytes(synthesize(params, seed)))
350
+ stamp.write_text(key)
351
+ written.append(target)
352
+ return written
File without changes