spawnpoint-studio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. tests/test_web.py +41 -0
@@ -0,0 +1,71 @@
1
+ class_name SPCameraRig
2
+ extends Camera2D
3
+ ## Follows the player with smoothing and look-ahead; trauma-based screen shake.
4
+ ##
5
+ ## Shake follows the "trauma" model: offset = max_offset * trauma² * noise(t). Trauma decays
6
+ ## linearly, so small hits produce subtle jitter and big hits a strong, short shake.
7
+
8
+ ## Maximum shake offset in pixels at trauma 1.
9
+ @export var max_shake_offset := Vector2(14, 10)
10
+ ## Maximum shake rotation in radians at trauma 1.
11
+ @export var max_shake_rotation := 0.04
12
+ ## Trauma lost per second.
13
+ @export var trauma_decay := 1.6
14
+ ## How far ahead of the player the camera looks, in pixels at full speed.
15
+ @export var look_ahead := Vector2(48, 0)
16
+ ## Smoothing speed of the look-ahead offset.
17
+ @export var look_ahead_speed := 3.0
18
+
19
+ var trauma := 0.0
20
+ var _noise := FastNoiseLite.new()
21
+ var _t := 0.0
22
+ var _look := Vector2.ZERO
23
+ var _target: Node2D
24
+
25
+
26
+ func _ready() -> void:
27
+ position_smoothing_enabled = true
28
+ if position_smoothing_speed <= 0.0:
29
+ position_smoothing_speed = 7.0
30
+ ignore_rotation = false
31
+ _noise.seed = randi()
32
+ _noise.frequency = 2.0
33
+ FX.register_camera(self)
34
+ make_current()
35
+ _snap_to_target.call_deferred()
36
+
37
+
38
+ func _exit_tree() -> void:
39
+ FX.unregister_camera(self)
40
+
41
+
42
+ func add_trauma(amount: float) -> void:
43
+ trauma = clampf(trauma + amount, 0.0, 1.0)
44
+
45
+
46
+ func _snap_to_target() -> void:
47
+ _target = World.player()
48
+ if _target:
49
+ global_position = _target.global_position
50
+ reset_smoothing()
51
+
52
+
53
+ func _process(delta: float) -> void:
54
+ if _target == null or not is_instance_valid(_target):
55
+ _target = World.player()
56
+ if _target:
57
+ var vel := Vector2.ZERO
58
+ if _target is CharacterBody2D:
59
+ vel = (_target as CharacterBody2D).velocity
60
+ var desired := Vector2(signf(vel.x) * look_ahead.x if absf(vel.x) > 20.0 else _look.x,
61
+ signf(vel.y) * look_ahead.y if absf(vel.y) > 20.0 else _look.y)
62
+ _look = _look.lerp(desired, clampf(look_ahead_speed * delta, 0.0, 1.0))
63
+ global_position = _target.global_position + _look
64
+
65
+ _t += delta
66
+ trauma = maxf(trauma - trauma_decay * delta, 0.0)
67
+ var s := trauma * trauma
68
+ offset = Vector2(
69
+ max_shake_offset.x * s * _noise.get_noise_2d(_t * 60.0, 0.0),
70
+ max_shake_offset.y * s * _noise.get_noise_2d(0.0, _t * 60.0))
71
+ rotation = max_shake_rotation * s * _noise.get_noise_2d(_t * 60.0, 100.0)
@@ -0,0 +1,62 @@
1
+ extends Node
2
+ ## Autoload "Cockpit": lets the Spawn Point web cockpit select entities in the running game.
3
+ ## Right-click an entity: it flashes, and on the web its type and node name are posted to the
4
+ ## parent page ({spawnpoint: "select", entity, node}). Harmless when not embedded.
5
+
6
+ signal selected(entity: Node)
7
+
8
+ ## How far (px) a click may land from an entity and still select it — sprites are small.
9
+ const PICK_RADIUS := 28.0
10
+
11
+
12
+ func _ready() -> void:
13
+ process_mode = Node.PROCESS_MODE_ALWAYS
14
+
15
+
16
+ func _unhandled_input(event: InputEvent) -> void:
17
+ var mb := event as InputEventMouseButton
18
+ if mb == null or not mb.pressed or mb.button_index != MOUSE_BUTTON_RIGHT:
19
+ return
20
+ if World.level == null or not is_instance_valid(World.level):
21
+ return
22
+ var e := entity_at(World.level.get_global_mouse_position())
23
+ if e:
24
+ select(e)
25
+ get_viewport().set_input_as_handled()
26
+
27
+
28
+ ## The entity at (or nearest to, within PICK_RADIUS) a world position, or null.
29
+ func entity_at(world_pos: Vector2) -> Node2D:
30
+ var q := PhysicsPointQueryParameters2D.new()
31
+ q.position = world_pos
32
+ q.collide_with_areas = true
33
+ q.collide_with_bodies = true
34
+ for hit in World.level.get_world_2d().direct_space_state.intersect_point(q, 16):
35
+ var e := SP.entity_of(hit.collider) as Node2D
36
+ if e and e.is_in_group(SP.ENTITY_GROUP):
37
+ return e
38
+ var best: Node2D = null
39
+ var best_d := PICK_RADIUS
40
+ for n in get_tree().get_nodes_in_group(SP.ENTITY_GROUP):
41
+ var e := n as Node2D
42
+ if e == null or not e.visible:
43
+ continue
44
+ var d := e.global_position.distance_to(world_pos)
45
+ if d < best_d:
46
+ best_d = d
47
+ best = e
48
+ return best
49
+
50
+
51
+ ## Highlights [param e] and reports it to the cockpit page.
52
+ func select(e: Node2D) -> void:
53
+ selected.emit(e)
54
+ var juice := SP.component(e, &"Juice")
55
+ if juice and juice.has_method(&"flash_white"):
56
+ juice.flash_white(0.35)
57
+ FX.burst(e.global_position, Color.WHITE, 10, 120.0, 0.3, 2.5, 0.0)
58
+ var core := SP.core(e)
59
+ var msg := JSON.stringify({"spawnpoint": "select", "entity": core.entity_type if core else String(e.name),
60
+ "node": String(e.name)})
61
+ if OS.has_feature("web"):
62
+ JavaScriptBridge.eval("window.parent && window.parent.postMessage(%s, '*')" % msg)
@@ -0,0 +1,98 @@
1
+ class_name SPEntityCore
2
+ extends Node
3
+ ## Core of every Spawn Point entity (always the child named "Entity").
4
+ ##
5
+ ## Registers the entity with [World], handles default death behaviour (players lose a life and
6
+ ## respawn, everything else is removed) and kills entities that fall out of the level.
7
+
8
+ signal respawned
9
+
10
+ ## Entity type name from the game graph ("Player", "Slime", ...).
11
+ @export var entity_type := ""
12
+ ## "player", "enemy" or "neutral".
13
+ @export var team := SP.TEAM_NEUTRAL
14
+ ## True for the entity controlled by the player.
15
+ @export var is_player := false
16
+ ## Seconds before a dead player respawns.
17
+ @export var respawn_delay := 0.9
18
+
19
+ var spawn_position := Vector2.ZERO
20
+ var dead := false
21
+ ## Why the entity last died: "fall", the attacking entity's type (e.g. "Spikes"), or "unknown".
22
+ ## Empty while alive.
23
+ var death_cause := ""
24
+
25
+
26
+ func _enter_tree() -> void:
27
+ var e := get_parent()
28
+ e.add_to_group(SP.ENTITY_GROUP)
29
+ if entity_type != "":
30
+ e.add_to_group(StringName(entity_type))
31
+ if e.has_meta(&"sp_team"):
32
+ team = String(e.get_meta(&"sp_team"))
33
+ e.add_to_group(StringName("team_" + team))
34
+ if is_player:
35
+ e.add_to_group(SP.PLAYER_GROUP)
36
+
37
+
38
+ func _ready() -> void:
39
+ var e := get_parent() as Node2D
40
+ spawn_position = e.global_position
41
+ var health := SP.component(e, &"Health") as HealthComponent
42
+ if health:
43
+ health.died.connect(_on_died)
44
+ World.register_entity(e)
45
+
46
+
47
+ func _physics_process(_delta: float) -> void:
48
+ var e := get_parent() as Node2D
49
+ if not dead and e.global_position.y > World.kill_y:
50
+ death_cause = "fall"
51
+ var health := SP.component(e, &"Health") as HealthComponent
52
+ if health:
53
+ health.kill()
54
+ else:
55
+ _on_died()
56
+
57
+
58
+ func _on_died() -> void:
59
+ if dead:
60
+ return
61
+ dead = true
62
+ var e := get_parent() as Node2D
63
+ if death_cause == "":
64
+ death_cause = _attacker_type(e)
65
+ if not is_player:
66
+ e.queue_free.call_deferred()
67
+ return
68
+ e.visible = false
69
+ e.set_deferred(&"process_mode", Node.PROCESS_MODE_DISABLED)
70
+ if GameState.player_died():
71
+ get_tree().create_timer(respawn_delay).timeout.connect(_respawn)
72
+
73
+
74
+ func _respawn() -> void:
75
+ var e := get_parent() as Node2D
76
+ if not is_instance_valid(e):
77
+ return
78
+ e.global_position = spawn_position
79
+ if e is CharacterBody2D:
80
+ (e as CharacterBody2D).velocity = Vector2.ZERO
81
+ var health := SP.component(e, &"Health") as HealthComponent
82
+ if health:
83
+ health.reset()
84
+ health.grant_invincibility(1.5)
85
+ dead = false
86
+ death_cause = ""
87
+ e.visible = true
88
+ e.process_mode = Node.PROCESS_MODE_INHERIT
89
+ respawned.emit()
90
+
91
+
92
+ func _attacker_type(e: Node) -> String:
93
+ var hb := SP.component(e, &"Hurtbox") as HurtboxComponent
94
+ if hb and is_instance_valid(hb.last_hit_by):
95
+ var c := SP.core(hb.last_hit_by)
96
+ if c and c.entity_type != "":
97
+ return c.entity_type
98
+ return "unknown"
@@ -0,0 +1,112 @@
1
+ extends Node
2
+ ## Autoload "FX": global game-feel effects — hit-stop, screen shake, particle bursts, floating text.
3
+
4
+ var _hitstop_token := 0
5
+ var _cameras: Array[Node] = []
6
+
7
+
8
+ func register_camera(cam: Node) -> void:
9
+ if cam not in _cameras:
10
+ _cameras.append(cam)
11
+
12
+
13
+ func unregister_camera(cam: Node) -> void:
14
+ _cameras.erase(cam)
15
+
16
+
17
+ ## Freezes the game for [param seconds] ("hit-stop"). The most recent call decides when it ends.
18
+ ## Timed in engine time (not wall-clock), so it also works when the game runs faster than real
19
+ ## time (fixed-fps tests, high refresh rates).
20
+ func hitstop(seconds: float) -> void:
21
+ if seconds <= 0.0 or not is_inside_tree():
22
+ return
23
+ _hitstop_token += 1
24
+ var token := _hitstop_token
25
+ Engine.time_scale = 0.0
26
+ await get_tree().create_timer(seconds, true, false, true).timeout
27
+ if token == _hitstop_token:
28
+ Engine.time_scale = 1.0
29
+
30
+
31
+ ## Adds trauma (0..1) to every active camera rig. Shake strength grows with trauma².
32
+ func shake(amount: float) -> void:
33
+ for cam in _cameras:
34
+ if is_instance_valid(cam):
35
+ cam.add_trauma(amount)
36
+
37
+
38
+ ## One-shot particle burst at [param pos].
39
+ func burst(pos: Vector2, color: Color, amount := 16, speed := 180.0, lifetime := 0.45, size := 4.0, gravity := 300.0) -> void:
40
+ var parent := _fx_parent()
41
+ if parent == null:
42
+ return
43
+ var p := CPUParticles2D.new()
44
+ p.one_shot = true
45
+ p.explosiveness = 0.95
46
+ p.amount = maxi(amount, 1)
47
+ p.lifetime = lifetime
48
+ p.direction = Vector2.UP
49
+ p.spread = 180.0
50
+ p.initial_velocity_min = speed * 0.4
51
+ p.initial_velocity_max = speed
52
+ p.gravity = Vector2(0, gravity)
53
+ p.damping_min = 40.0
54
+ p.damping_max = 80.0
55
+ p.scale_amount_min = size * 0.5
56
+ p.scale_amount_max = size
57
+ var curve := Curve.new()
58
+ curve.add_point(Vector2(0, 1))
59
+ curve.add_point(Vector2(1, 0))
60
+ p.scale_amount_curve = curve
61
+ p.color = color
62
+ p.position = pos
63
+ p.z_index = 10
64
+ parent.add_child(p)
65
+ p.emitting = true
66
+ p.finished.connect(p.queue_free)
67
+
68
+
69
+ ## Rising, fading text (e.g. "+1") at [param pos].
70
+ func float_text(pos: Vector2, text: String, color := Color.WHITE) -> void:
71
+ var parent := _fx_parent()
72
+ if parent == null:
73
+ return
74
+ var label := Label.new()
75
+ label.text = text
76
+ label.add_theme_font_size_override(&"font_size", 18)
77
+ label.add_theme_color_override(&"font_color", color)
78
+ label.add_theme_color_override(&"font_outline_color", Color(0, 0, 0, 0.8))
79
+ label.add_theme_constant_override(&"outline_size", 4)
80
+ label.position = pos - Vector2(20, 24)
81
+ label.z_index = 20
82
+ parent.add_child(label)
83
+ var t := label.create_tween().set_parallel(true)
84
+ t.tween_property(label, ^"position:y", label.position.y - 28.0, 0.6).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_CUBIC)
85
+ t.tween_property(label, ^"modulate:a", 0.0, 0.6).set_delay(0.25)
86
+ t.chain().tween_callback(label.queue_free)
87
+
88
+
89
+ ## Fading copy of [param visual] — used for dash after-images.
90
+ func ghost(visual: Node2D, color := Color(1, 1, 1, 0.5)) -> void:
91
+ var parent := _fx_parent()
92
+ if parent == null or visual == null:
93
+ return
94
+ var copy := visual.duplicate(0) as Node2D
95
+ copy.material = null
96
+ for c in copy.find_children("*", "CanvasItem", true, false):
97
+ (c as CanvasItem).material = null
98
+ copy.modulate = color
99
+ copy.z_index = -1
100
+ parent.add_child(copy)
101
+ copy.global_transform = visual.global_transform
102
+ var t := copy.create_tween()
103
+ t.tween_property(copy, ^"modulate:a", 0.0, 0.25)
104
+ t.tween_callback(copy.queue_free)
105
+
106
+
107
+ func _fx_parent() -> Node:
108
+ if not is_inside_tree():
109
+ return null
110
+ if World.level and is_instance_valid(World.level):
111
+ return World.level
112
+ return get_tree().current_scene
@@ -0,0 +1,111 @@
1
+ extends Node
2
+ ## Autoload "GameState": score, lives, rule variables and the win/lose flow.
3
+
4
+ signal score_changed(score: int)
5
+ signal lives_changed(lives: int)
6
+ signal var_changed(name: String, value: Variant)
7
+ signal message_posted(text: String)
8
+ ## Emitted once per whole second of play time (survival goals, timed events).
9
+ signal second_passed(seconds: int)
10
+ signal game_won
11
+ signal game_lost
12
+
13
+ enum Phase { PLAYING, WON, LOST }
14
+
15
+ var score := 0
16
+ var lives := 3
17
+ var start_lives := 3
18
+ var time := 0.0
19
+ var phase := Phase.PLAYING
20
+ var vars := {}
21
+
22
+ var _started := false
23
+
24
+
25
+ ## Called by the level on load. Resets everything on the first load and after restart().
26
+ func begin_level(level_lives: int) -> void:
27
+ if _started:
28
+ phase = Phase.PLAYING
29
+ return
30
+ _started = true
31
+ start_lives = level_lives
32
+ score = 0
33
+ lives = level_lives
34
+ time = 0.0
35
+ vars.clear()
36
+ phase = Phase.PLAYING
37
+
38
+
39
+ func _process(delta: float) -> void:
40
+ if phase == Phase.PLAYING:
41
+ var before := int(time)
42
+ time += delta
43
+ if int(time) > before:
44
+ second_passed.emit(int(time))
45
+
46
+
47
+ func is_playing() -> bool:
48
+ return phase == Phase.PLAYING
49
+
50
+
51
+ func add_score(amount: int) -> void:
52
+ if amount == 0:
53
+ return
54
+ score += amount
55
+ score_changed.emit(score)
56
+
57
+
58
+ func set_var(var_name: String, value: Variant) -> void:
59
+ vars[var_name] = value
60
+ var_changed.emit(var_name, value)
61
+
62
+
63
+ func add_var(var_name: String, delta: float) -> void:
64
+ set_var(var_name, get_var(var_name) + delta)
65
+
66
+
67
+ func get_var(var_name: String) -> Variant:
68
+ return vars.get(var_name, 0)
69
+
70
+
71
+ func message(text: String) -> void:
72
+ message_posted.emit(text)
73
+
74
+
75
+ ## The player lost a life. Returns true if the player may respawn.
76
+ func player_died() -> bool:
77
+ if phase != Phase.PLAYING:
78
+ return false
79
+ lives = maxi(lives - 1, 0)
80
+ lives_changed.emit(lives)
81
+ if lives <= 0:
82
+ lose()
83
+ return false
84
+ return true
85
+
86
+
87
+ func win() -> void:
88
+ if phase != Phase.PLAYING:
89
+ return
90
+ phase = Phase.WON
91
+ game_won.emit()
92
+
93
+
94
+ func lose() -> void:
95
+ if phase != Phase.PLAYING:
96
+ return
97
+ phase = Phase.LOST
98
+ game_lost.emit()
99
+
100
+
101
+ ## Resets the run state without reloading the scene (used by tests and tools).
102
+ func reset_run(level_lives := 3) -> void:
103
+ _started = false
104
+ begin_level(level_lives)
105
+
106
+
107
+ ## Full restart: score, lives and variables reset, level reloads.
108
+ func restart() -> void:
109
+ _started = false
110
+ Engine.time_scale = 1.0
111
+ get_tree().reload_current_scene()
@@ -0,0 +1,155 @@
1
+ class_name SPHud
2
+ extends CanvasLayer
3
+ ## Score, lives, player health, toast messages and the win / game-over overlay.
4
+ ## Press R at any time (or Enter after the game ends) to restart.
5
+
6
+ const TEXT_COLOR := Color("#fff1e8")
7
+ const ACCENT := Color("#ffec27")
8
+ const DANGER := Color("#ff004d")
9
+
10
+ var _score: Label
11
+ var _lives: Label
12
+ var _hearts: HBoxContainer
13
+ var _toast: Label
14
+ var _overlay: Control
15
+ var _overlay_title: Label
16
+ var _toast_tween: Tween
17
+ var _shown_health := -1
18
+
19
+
20
+ func _ready() -> void:
21
+ layer = 10
22
+ process_mode = Node.PROCESS_MODE_ALWAYS
23
+ _build()
24
+ GameState.score_changed.connect(_on_score)
25
+ GameState.lives_changed.connect(_on_lives)
26
+ GameState.message_posted.connect(show_message)
27
+ GameState.game_won.connect(_show_overlay.bind("YOU WIN!", ACCENT))
28
+ GameState.game_lost.connect(_show_overlay.bind("GAME OVER", DANGER))
29
+ _on_score(GameState.score)
30
+ _on_lives(GameState.lives)
31
+
32
+
33
+ func _process(_delta: float) -> void:
34
+ var p := World.player()
35
+ var h: HealthComponent = SP.component(p, &"Health") as HealthComponent if p else null
36
+ var cur: int = h.current if h else -1
37
+ if cur != _shown_health:
38
+ _shown_health = cur
39
+ _refresh_hearts(h)
40
+
41
+
42
+ func _unhandled_input(event: InputEvent) -> void:
43
+ if event is InputEventKey and event.pressed and not event.echo:
44
+ var key := (event as InputEventKey).keycode
45
+ if key == KEY_R or (not GameState.is_playing() and key in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]):
46
+ GameState.restart()
47
+
48
+
49
+ func show_message(text: String) -> void:
50
+ _toast.text = text
51
+ _toast.modulate.a = 1.0
52
+ _toast.scale = Vector2(1.3, 1.3)
53
+ if _toast_tween:
54
+ _toast_tween.kill()
55
+ _toast_tween = _toast.create_tween()
56
+ _toast_tween.tween_property(_toast, ^"scale", Vector2.ONE, 0.25).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
57
+ _toast_tween.tween_interval(1.4)
58
+ _toast_tween.tween_property(_toast, ^"modulate:a", 0.0, 0.5)
59
+
60
+
61
+ func _on_score(score: int) -> void:
62
+ _score.text = "SCORE %d" % score
63
+ _score.pivot_offset = _score.size * 0.5
64
+ var t := _score.create_tween()
65
+ _score.scale = Vector2(1.25, 1.25)
66
+ t.tween_property(_score, ^"scale", Vector2.ONE, 0.2).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
67
+
68
+
69
+ func _on_lives(lives: int) -> void:
70
+ _lives.text = "LIVES %d" % lives
71
+
72
+
73
+ func _refresh_hearts(h: HealthComponent) -> void:
74
+ for c in _hearts.get_children():
75
+ c.queue_free()
76
+ if h == null:
77
+ return
78
+ for i in h.max_health:
79
+ var r := ColorRect.new()
80
+ r.custom_minimum_size = Vector2(14, 14)
81
+ r.color = DANGER if i < h.current else Color(1, 1, 1, 0.15)
82
+ _hearts.add_child(r)
83
+
84
+
85
+ func _show_overlay(title: String, color: Color) -> void:
86
+ _overlay_title.text = title
87
+ _overlay_title.add_theme_color_override(&"font_color", color)
88
+ _overlay.visible = true
89
+ _overlay.modulate.a = 0.0
90
+ _overlay.create_tween().tween_property(_overlay, ^"modulate:a", 1.0, 0.35)
91
+
92
+
93
+ func _label(size: int, color := TEXT_COLOR) -> Label:
94
+ var l := Label.new()
95
+ l.add_theme_font_size_override(&"font_size", size)
96
+ l.add_theme_color_override(&"font_color", color)
97
+ l.add_theme_color_override(&"font_outline_color", Color(0, 0, 0, 0.85))
98
+ l.add_theme_constant_override(&"outline_size", maxi(size / 5, 3))
99
+ return l
100
+
101
+
102
+ func _build() -> void:
103
+ var root := Control.new()
104
+ root.set_anchors_preset(Control.PRESET_FULL_RECT)
105
+ root.mouse_filter = Control.MOUSE_FILTER_IGNORE
106
+ add_child(root)
107
+
108
+ var top := HBoxContainer.new()
109
+ top.set_anchors_preset(Control.PRESET_TOP_WIDE)
110
+ top.offset_left = 16
111
+ top.offset_right = -16
112
+ top.offset_top = 10
113
+ top.add_theme_constant_override(&"separation", 24)
114
+ root.add_child(top)
115
+ _score = _label(26, ACCENT)
116
+ top.add_child(_score)
117
+ _hearts = HBoxContainer.new()
118
+ _hearts.size_flags_vertical = Control.SIZE_SHRINK_CENTER
119
+ _hearts.add_theme_constant_override(&"separation", 4)
120
+ _hearts.alignment = BoxContainer.ALIGNMENT_CENTER
121
+ top.add_child(_hearts)
122
+ var spacer := Control.new()
123
+ spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
124
+ top.add_child(spacer)
125
+ _lives = _label(22)
126
+ top.add_child(_lives)
127
+
128
+ _toast = _label(34)
129
+ _toast.set_anchors_preset(Control.PRESET_CENTER_TOP)
130
+ _toast.offset_top = 70
131
+ _toast.offset_left = -300
132
+ _toast.offset_right = 300
133
+ _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
134
+ _toast.pivot_offset = Vector2(300, 20)
135
+ _toast.modulate.a = 0.0
136
+ root.add_child(_toast)
137
+
138
+ _overlay = ColorRect.new()
139
+ (_overlay as ColorRect).color = Color(0, 0, 0, 0.55)
140
+ _overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
141
+ _overlay.visible = false
142
+ root.add_child(_overlay)
143
+ var box := VBoxContainer.new()
144
+ box.set_anchors_preset(Control.PRESET_CENTER)
145
+ box.grow_horizontal = Control.GROW_DIRECTION_BOTH
146
+ box.grow_vertical = Control.GROW_DIRECTION_BOTH
147
+ box.alignment = BoxContainer.ALIGNMENT_CENTER
148
+ _overlay.add_child(box)
149
+ _overlay_title = _label(64)
150
+ _overlay_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
151
+ box.add_child(_overlay_title)
152
+ var hint := _label(22)
153
+ hint.text = "Press R to play again"
154
+ hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
155
+ box.add_child(hint)
@@ -0,0 +1,19 @@
1
+ class_name SPLevel
2
+ extends Node2D
3
+ ## Root of a generated level. Configures [World] and [GameState] before entities load.
4
+
5
+ ## "platformer" or "topdown".
6
+ @export var genre := "platformer"
7
+ ## Lives at the start of a run.
8
+ @export var lives := 3
9
+ ## Tile size in pixels.
10
+ @export var tile_size := 32
11
+ ## Level bounds in pixels.
12
+ @export var bounds := Rect2(0, 0, 960, 540)
13
+
14
+
15
+ func _enter_tree() -> void:
16
+ var kill_y := bounds.end.y + tile_size * 4.0 if genre == "platformer" else INF
17
+ World.begin_level(self, kill_y)
18
+ GameState.begin_level(lives)
19
+ Engine.time_scale = 1.0
@@ -0,0 +1,58 @@
1
+ extends Node
2
+ ## Autoload "Sfx": pooled one-shot sound playback with pitch variation.
3
+ ## Sounds are loaded from res://sfx/<name>.wav (generated by the compiler).
4
+
5
+ const SFX_PATH := "res://sfx/%s.wav"
6
+ const VOICES := 12
7
+
8
+ var _players: Array[AudioStreamPlayer] = []
9
+ var _next := 0
10
+ var _streams := {}
11
+ var _combo := {} # name -> {"until": msec, "step": int}
12
+
13
+
14
+ func _ready() -> void:
15
+ process_mode = Node.PROCESS_MODE_ALWAYS
16
+ for i in VOICES:
17
+ var p := AudioStreamPlayer.new()
18
+ add_child(p)
19
+ _players.append(p)
20
+
21
+
22
+ ## Plays sound [param sound_name]. [param pitch_variance] randomizes pitch by ±that fraction.
23
+ func play(sound_name: String, pitch_variance := 0.08, volume_db := 0.0, pitch := 1.0) -> void:
24
+ var stream := _stream(sound_name)
25
+ if stream == null:
26
+ return
27
+ var p := _players[_next]
28
+ _next = (_next + 1) % _players.size()
29
+ p.stream = stream
30
+ p.volume_db = volume_db
31
+ p.pitch_scale = maxf(0.05, pitch * (1.0 + randf_range(-pitch_variance, pitch_variance)))
32
+ p.play()
33
+
34
+
35
+ ## Like play(), but each call within [param window] seconds of the previous one is a semitone
36
+ ## higher (coin pickup chains).
37
+ func play_combo(sound_name: String, window := 0.8, max_steps := 12) -> void:
38
+ var now := Time.get_ticks_msec()
39
+ var c: Dictionary = _combo.get(sound_name, {"until": 0, "step": -1})
40
+ c.step = mini(c.step + 1, max_steps) if now < c.until else 0
41
+ c.until = now + int(window * 1000.0)
42
+ _combo[sound_name] = c
43
+ play(sound_name, 0.0, 0.0, pow(2.0, c.step / 12.0))
44
+
45
+
46
+ func has_sound(sound_name: String) -> bool:
47
+ return _stream(sound_name) != null
48
+
49
+
50
+ func _stream(sound_name: String) -> AudioStream:
51
+ if sound_name.is_empty():
52
+ return null
53
+ if _streams.has(sound_name):
54
+ return _streams[sound_name]
55
+ var path := SFX_PATH % sound_name
56
+ var s: AudioStream = load(path) if ResourceLoader.exists(path) else null
57
+ _streams[sound_name] = s
58
+ return s