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,58 @@
1
+ class_name CollectibleComponent
2
+ extends Node
3
+ ## A pickup (coin, gem, key...). Touching it adds [member score], emits [signal collected] and
4
+ ## removes it. The visual bobs gently so pickups read as "take me".
5
+
6
+ const SP_NAME := "Collectible"
7
+ const SP_HOSTS: Array[String] = ["Area2D"]
8
+ const SP_REQUIRES: Array[String] = []
9
+ const SP_CATEGORY := "core"
10
+
11
+ ## Emitted when collected; [param by] is the collecting entity.
12
+ signal collected(by: Node)
13
+
14
+ ## Free-form kind, usable in rules ("coin", "key", "heart").
15
+ @export var kind: String = "coin"
16
+ ## Score added on pickup (0 = none).
17
+ @export_range(0, 100000) var score: int = 1
18
+ ## Health restored to the collector on pickup (0 = none).
19
+ @export_range(0, 999) var heal: int = 0
20
+ ## Bobbing height in px (0 = static).
21
+ @export_range(0.0, 32.0, 0.5) var bob_height: float = 3.0
22
+
23
+ var _entity: Area2D
24
+ var _t := 0.0
25
+ var _taken := false
26
+ var _base_y := 0.0
27
+
28
+
29
+ func _ready() -> void:
30
+ _entity = get_parent() as Area2D
31
+ _entity.body_entered.connect(_on_body_entered)
32
+ _t = randf() * TAU
33
+ var visual := _entity.get_node_or_null(^"Visual") as Node2D
34
+ if visual:
35
+ _base_y = visual.position.y
36
+
37
+
38
+ func _process(delta: float) -> void:
39
+ if bob_height <= 0.0:
40
+ return
41
+ _t += delta * 3.0
42
+ var visual := _entity.get_node_or_null(^"Visual") as Node2D
43
+ if visual:
44
+ visual.position.y = _base_y + sin(_t) * bob_height
45
+
46
+
47
+ func _on_body_entered(body: Node) -> void:
48
+ if _taken or not body.is_in_group(SP.PLAYER_GROUP):
49
+ return
50
+ _taken = true
51
+ # Free first so rules reacting to these signals already see one pickup fewer in World.count().
52
+ _entity.queue_free()
53
+ if heal > 0:
54
+ var h := SP.component(body, &"Health") as HealthComponent
55
+ if h:
56
+ h.heal(heal)
57
+ collected.emit(body)
58
+ GameState.add_score(score)
@@ -0,0 +1,64 @@
1
+ class_name GateComponent
2
+ extends Node
3
+ ## A solid barrier that can be opened and closed by rules (doors, bridges, locked exits).
4
+
5
+ const SP_NAME := "Gate"
6
+ const SP_HOSTS: Array[String] = ["StaticBody2D"]
7
+ const SP_REQUIRES: Array[String] = []
8
+ const SP_CATEGORY := "core"
9
+
10
+ ## Emitted when the gate opens.
11
+ signal opened
12
+ ## Emitted when the gate closes.
13
+ signal closed
14
+
15
+ ## Start open.
16
+ @export var starts_open := false
17
+
18
+ var is_open := false
19
+ var _entity: StaticBody2D
20
+
21
+
22
+ func _ready() -> void:
23
+ _entity = get_parent() as StaticBody2D
24
+ _set_open(starts_open, false)
25
+
26
+
27
+ ## Opens the gate (no collision, faded visual).
28
+ func open() -> void:
29
+ if not is_open:
30
+ _set_open(true, true)
31
+ opened.emit()
32
+
33
+
34
+ ## Closes the gate.
35
+ func close() -> void:
36
+ if is_open:
37
+ _set_open(false, true)
38
+ closed.emit()
39
+
40
+
41
+ ## Opens if closed, closes if open.
42
+ func toggle() -> void:
43
+ if is_open:
44
+ close()
45
+ else:
46
+ open()
47
+
48
+
49
+ func _set_open(value: bool, animate: bool) -> void:
50
+ is_open = value
51
+ for cs in _entity.get_children():
52
+ if cs is CollisionShape2D or cs is CollisionPolygon2D:
53
+ cs.set_deferred(&"disabled", value)
54
+ var visual := _entity.get_node_or_null(^"Visual") as Node2D
55
+ if visual == null:
56
+ return
57
+ var alpha := 0.2 if value else 1.0
58
+ if animate:
59
+ var t := visual.create_tween().set_parallel(true)
60
+ t.tween_property(visual, ^"modulate:a", alpha, 0.25)
61
+ visual.scale = Vector2(1.15, 0.85) if value else Vector2(0.9, 1.1)
62
+ t.tween_property(visual, ^"scale", Vector2.ONE, 0.3).set_trans(Tween.TRANS_ELASTIC).set_ease(Tween.EASE_OUT)
63
+ else:
64
+ visual.modulate.a = alpha
@@ -0,0 +1,83 @@
1
+ class_name HealthComponent
2
+ extends Node
3
+ ## Hit points with brief invincibility after each hit.
4
+ ## When health reaches zero the entity dies (players respawn while lives remain).
5
+
6
+ const SP_NAME := "Health"
7
+ const SP_HOSTS: Array[String] = []
8
+ const SP_REQUIRES: Array[String] = []
9
+ const SP_CATEGORY := "core"
10
+
11
+ ## Emitted after taking damage that was not blocked.
12
+ signal damaged(amount: int)
13
+ ## Emitted after healing.
14
+ signal healed(amount: int)
15
+ ## Emitted once when health reaches zero.
16
+ signal died
17
+
18
+ ## Maximum (and starting) hit points.
19
+ @export_range(1, 999) var max_health: int = 3
20
+ ## Seconds of invincibility after being hit (prevents instant multi-hits).
21
+ @export_range(0.0, 5.0, 0.01) var invincibility_time: float = 0.6
22
+
23
+ var current: int
24
+ var _invincible_left := 0.0
25
+
26
+
27
+ func _ready() -> void:
28
+ current = max_health
29
+
30
+
31
+ func _physics_process(delta: float) -> void:
32
+ if _invincible_left > 0.0:
33
+ _invincible_left -= delta
34
+
35
+
36
+ ## Deals [param amount] damage unless invincible or dead.
37
+ func damage(amount: int) -> void:
38
+ if amount <= 0 or is_dead() or is_invincible():
39
+ return
40
+ current = maxi(current - amount, 0)
41
+ _invincible_left = invincibility_time
42
+ damaged.emit(amount)
43
+ if current == 0:
44
+ died.emit()
45
+
46
+
47
+ ## Restores up to [param amount] hit points.
48
+ func heal(amount: int) -> void:
49
+ if amount <= 0 or is_dead():
50
+ return
51
+ var before := current
52
+ current = mini(current + amount, max_health)
53
+ if current > before:
54
+ healed.emit(current - before)
55
+
56
+
57
+ ## Kills the entity immediately, ignoring invincibility.
58
+ func kill() -> void:
59
+ if is_dead():
60
+ return
61
+ current = 0
62
+ died.emit()
63
+
64
+
65
+ ## Restores full health.
66
+ func reset() -> void:
67
+ current = max_health
68
+ _invincible_left = 0.0
69
+
70
+
71
+ ## Makes the entity invincible for [param seconds].
72
+ func grant_invincibility(seconds: float) -> void:
73
+ _invincible_left = maxf(_invincible_left, seconds)
74
+
75
+
76
+ ## True while hits are ignored.
77
+ func is_invincible() -> bool:
78
+ return _invincible_left > 0.0
79
+
80
+
81
+ ## True when health is zero.
82
+ func is_dead() -> bool:
83
+ return current <= 0
@@ -0,0 +1,50 @@
1
+ class_name HitboxComponent
2
+ extends Area2D
3
+ ## Deals damage to hurtboxes of the opposing team on contact (enemy bodies, bullets, spikes).
4
+
5
+ const SP_NAME := "Hitbox"
6
+ const SP_HOSTS: Array[String] = []
7
+ const SP_REQUIRES: Array[String] = []
8
+ const SP_INTERNAL: Array[String] = ["refresh_team"]
9
+ const SP_CATEGORY := "core"
10
+
11
+ ## Emitted when this hitbox damages [param target] (the entity that was hit).
12
+ signal hit(target: Node)
13
+
14
+ ## Damage per hit.
15
+ @export_range(0, 999) var damage: int = 1
16
+ ## Push applied to the victim, in pixels/second.
17
+ @export_range(0.0, 2000.0, 1.0) var knockback: float = 260.0
18
+ ## Hitbox size relative to the entity shape.
19
+ @export_range(0.1, 2.0, 0.05) var size_scale: float = 1.0
20
+ ## Neutral hitboxes (traps) hurt both teams.
21
+ @export var hurts_own_team := false
22
+
23
+ var _entity: Node2D
24
+
25
+
26
+ func _ready() -> void:
27
+ _entity = SP.entity_of(get_parent()) as Node2D
28
+ monitorable = false
29
+ monitoring = true
30
+ collision_layer = 0
31
+ refresh_team()
32
+
33
+
34
+ ## Re-reads the entity team (call after changing it at runtime).
35
+ func refresh_team() -> void:
36
+ var team := SP.team_of(_entity)
37
+ collision_mask = SP.opposing_hurtbox_mask(team)
38
+ if hurts_own_team:
39
+ collision_mask |= SP.hurtbox_layer(team)
40
+
41
+
42
+ func _physics_process(_delta: float) -> void:
43
+ # Polling overlaps (instead of area_entered) keeps damaging a victim that stays inside us
44
+ # once its invincibility ends.
45
+ for area in get_overlapping_areas():
46
+ var hb := area as HurtboxComponent
47
+ if hb == null or SP.entity_of(hb) == _entity:
48
+ continue
49
+ if hb.take_hit(damage, global_position, knockback, _entity):
50
+ hit.emit(SP.entity_of(hb))
@@ -0,0 +1,48 @@
1
+ class_name HurtboxComponent
2
+ extends Area2D
3
+ ## The area where this entity can be hit. Forwards damage to Health and pushes the entity back.
4
+
5
+ const SP_NAME := "Hurtbox"
6
+ const SP_HOSTS: Array[String] = []
7
+ const SP_REQUIRES: Array[String] = ["Health"]
8
+ const SP_INTERNAL: Array[String] = ["take_hit"]
9
+ const SP_CATEGORY := "core"
10
+
11
+ ## Emitted when a hit lands (after invincibility checks).
12
+ signal hurt(amount: int)
13
+
14
+ ## Hurtbox size relative to the entity shape. Below 1 is more forgiving for the player.
15
+ @export_range(0.1, 2.0, 0.05) var size_scale: float = 0.85
16
+ ## Multiplies knockback received.
17
+ @export_range(0.0, 5.0, 0.05) var knockback_scale: float = 1.0
18
+
19
+ ## The entity whose hit landed last (attribution for death causes and playtest telemetry).
20
+ var last_hit_by: Node
21
+
22
+ var _entity: Node2D
23
+
24
+
25
+ func _ready() -> void:
26
+ _entity = SP.entity_of(get_parent()) as Node2D
27
+ monitoring = false
28
+ monitorable = true
29
+ collision_mask = 0
30
+ collision_layer = SP.hurtbox_layer(SP.team_of(_entity))
31
+
32
+
33
+ ## Applies a hit from [param source] coming from [param from_position]. Returns true if damage
34
+ ## was dealt.
35
+ func take_hit(amount: int, from_position: Vector2, knockback: float, source: Node = null) -> bool:
36
+ var health := SP.component(_entity, &"Health") as HealthComponent
37
+ if health == null or health.is_dead() or health.is_invincible():
38
+ return false
39
+ last_hit_by = source
40
+ health.damage(amount)
41
+ hurt.emit(amount)
42
+ var mv := SP.movement(_entity)
43
+ if mv and knockback > 0.0 and mv.has_method(&"apply_knockback"):
44
+ var dir := (_entity.global_position - from_position).normalized()
45
+ if dir == Vector2.ZERO:
46
+ dir = Vector2.UP
47
+ mv.apply_knockback(dir * knockback * knockback_scale)
48
+ return true
@@ -0,0 +1,219 @@
1
+ class_name JuiceComponent
2
+ extends Node
3
+ ## Game feel for one entity. Wires itself to whatever sibling components exist and adds
4
+ ## feedback: squash & stretch on jump/land, white flash + shake + hit-stop when hurt,
5
+ ## after-images on dash, particle bursts on death and pickup, recoil shake and sounds.
6
+ ## Attached automatically to every entity unless the game disables juice.
7
+
8
+ const SP_NAME := "Juice"
9
+ const SP_HOSTS: Array[String] = []
10
+ const SP_REQUIRES: Array[String] = []
11
+ const SP_CATEGORY := "juice"
12
+
13
+ const FLASH_SHADER := preload("res://addons/spawn_point/shaders/flash.gdshader")
14
+
15
+ ## Squash & stretch on jump, land and hit.
16
+ @export var squash := true
17
+ ## Squash strength (0.3 = 30% deformation).
18
+ @export_range(0.0, 0.8, 0.01) var squash_amount: float = 0.28
19
+ ## Flash white when damaged.
20
+ @export var flash := true
21
+ ## Screen shake when this entity is hurt (0..1 trauma).
22
+ @export_range(0.0, 1.0, 0.01) var shake_on_hurt: float = 0.35
23
+ ## Screen shake when this entity dies.
24
+ @export_range(0.0, 1.0, 0.01) var shake_on_death: float = 0.45
25
+ ## Screen shake when this entity fires.
26
+ @export_range(0.0, 1.0, 0.01) var shake_on_fire: float = 0.06
27
+ ## Hit-stop (freeze frame) seconds when hurt.
28
+ @export_range(0.0, 0.3, 0.005) var hitstop_on_hurt: float = 0.06
29
+ ## Hit-stop seconds when killed.
30
+ @export_range(0.0, 0.3, 0.005) var hitstop_on_death: float = 0.04
31
+ ## Particle effects (death burst, landing dust, pickup sparkle).
32
+ @export var particles := true
33
+ ## After-images while dashing.
34
+ @export var dash_trail := true
35
+ ## Turn the visual to face the movement direction.
36
+ @export var face_movement := true
37
+ ## Play sound effects.
38
+ @export var sounds := true
39
+ ## Random pitch variation for sounds (0.1 = ±10%).
40
+ @export_range(0.0, 0.5, 0.01) var pitch_variance: float = 0.1
41
+ ## Sound played on jump.
42
+ @export var sound_jump: String = "jump"
43
+ ## Sound played on a hard landing.
44
+ @export var sound_land: String = "land"
45
+ ## Sound played when damaged.
46
+ @export var sound_hurt: String = "hurt"
47
+ ## Sound played on death.
48
+ @export var sound_death: String = "explosion"
49
+ ## Sound played when the Weapon fires.
50
+ @export var sound_fire: String = "shoot"
51
+ ## Sound played when collected (pitch rises on quick chains).
52
+ @export var sound_collect: String = "coin"
53
+ ## Sound played on dash.
54
+ @export var sound_dash: String = "dash"
55
+
56
+ var _entity: Node2D
57
+ var _visual: Node2D
58
+ var _shape: Node2D
59
+ var _mat: ShaderMaterial
60
+ var _squash_tween: Tween
61
+ var _flash_tween: Tween
62
+ var _ghost_timer := 0.0
63
+ var _ghost_accum := 0.0
64
+ var _base_scale := Vector2.ONE
65
+
66
+
67
+ func _ready() -> void:
68
+ _entity = SP.entity_of(get_parent()) as Node2D
69
+ _visual = _entity.get_node_or_null(^"Visual") as Node2D
70
+ _shape = _entity.get_node_or_null(^"Visual/Shape") as Node2D
71
+ if _visual:
72
+ _base_scale = _visual.scale
73
+ if flash:
74
+ _mat = ShaderMaterial.new()
75
+ _mat.shader = FLASH_SHADER
76
+ _visual.material = _mat
77
+ for ci in _visual.find_children("*", "CanvasItem", true, false):
78
+ (ci as CanvasItem).material = _mat
79
+ _wire.call_deferred()
80
+
81
+
82
+ func _wire() -> void:
83
+ var mv := SP.movement(_entity)
84
+ if mv:
85
+ if mv.has_signal(&"jumped"):
86
+ mv.jumped.connect(_on_jumped)
87
+ if mv.has_signal(&"landed"):
88
+ mv.landed.connect(_on_landed)
89
+ if mv.has_signal(&"dashed"):
90
+ mv.dashed.connect(_on_dashed)
91
+ var health := SP.component(_entity, &"Health") as HealthComponent
92
+ if health:
93
+ health.damaged.connect(_on_damaged)
94
+ health.died.connect(_on_died)
95
+ var weapon := SP.component(_entity, &"Weapon") as WeaponComponent
96
+ if weapon:
97
+ weapon.fired.connect(_on_fired)
98
+ var collectible := SP.component(_entity, &"Collectible") as CollectibleComponent
99
+ if collectible:
100
+ collectible.collected.connect(_on_collected)
101
+ var projectile := SP.component(_entity, &"Projectile") as ProjectileComponent
102
+ if projectile:
103
+ projectile.expired.connect(_on_projectile_expired)
104
+ var stomp := SP.component(_entity, &"Stomp") as StompComponent
105
+ if stomp:
106
+ stomp.stomped.connect(_on_stomped)
107
+ var gate := SP.component(_entity, &"Gate") as GateComponent
108
+ if gate:
109
+ gate.opened.connect(_play.bind("powerup"))
110
+
111
+
112
+ func _process(delta: float) -> void:
113
+ if _ghost_timer > 0.0:
114
+ _ghost_timer -= delta
115
+ _ghost_accum += delta
116
+ if _ghost_accum >= 0.03:
117
+ _ghost_accum = 0.0
118
+ FX.ghost(_visual, Color(1, 1, 1, 0.45))
119
+ if not face_movement or _shape == null:
120
+ return
121
+ var mv := SP.movement(_entity)
122
+ if mv is PlatformerMovementComponent:
123
+ SP.face(_entity, Vector2((mv as PlatformerMovementComponent).facing, 0), false)
124
+ elif mv is TopDownMovementComponent and SP.component(_entity, &"Aim") == null:
125
+ SP.face(_entity, (mv as TopDownMovementComponent).facing_dir, true, clampf(delta * 14.0, 0.0, 1.0))
126
+
127
+
128
+ ## Squash (x wider, y shorter) when [param amount] > 0, stretch when < 0, then spring back.
129
+ func squash_stretch(amount: float, duration := 0.22) -> void:
130
+ if not squash or _visual == null:
131
+ return
132
+ if _squash_tween:
133
+ _squash_tween.kill()
134
+ _visual.scale = _base_scale * Vector2(1.0 + amount, 1.0 - amount)
135
+ _squash_tween = _visual.create_tween()
136
+ _squash_tween.tween_property(_visual, ^"scale", _base_scale, duration).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
137
+
138
+
139
+ ## Flashes the visual white for [param duration] seconds.
140
+ func flash_white(duration := 0.12) -> void:
141
+ if _mat == null:
142
+ return
143
+ if _flash_tween:
144
+ _flash_tween.kill()
145
+ _mat.set_shader_parameter(&"flash", 1.0)
146
+ _flash_tween = _visual.create_tween()
147
+ _flash_tween.tween_method(func(v: float) -> void: _mat.set_shader_parameter(&"flash", v), 1.0, 0.0, duration)
148
+
149
+
150
+ func _on_jumped() -> void:
151
+ squash_stretch(-squash_amount)
152
+ _play(sound_jump)
153
+
154
+
155
+ func _on_landed(impact_speed: float) -> void:
156
+ var k := clampf(impact_speed / 900.0, 0.2, 1.0)
157
+ squash_stretch(squash_amount * k)
158
+ if particles and impact_speed > 250.0:
159
+ var feet := _entity.global_position + Vector2(0, SP.half_size(_entity).y)
160
+ FX.burst(feet, Color(1, 1, 1, 0.55), int(6 + 8 * k), 90.0 * k + 40.0, 0.35, 3.0, -60.0)
161
+ if impact_speed > 250.0:
162
+ _play(sound_land, -6.0)
163
+
164
+
165
+ func _on_dashed() -> void:
166
+ if dash_trail:
167
+ _ghost_timer = 0.15
168
+ squash_stretch(-squash_amount * 0.6, 0.15)
169
+ _play(sound_dash)
170
+
171
+
172
+ func _on_damaged(_amount: int) -> void:
173
+ if flash:
174
+ flash_white()
175
+ squash_stretch(squash_amount * 0.8, 0.18)
176
+ FX.shake(shake_on_hurt)
177
+ FX.hitstop(hitstop_on_hurt)
178
+ _play(sound_hurt)
179
+
180
+
181
+ func _on_died() -> void:
182
+ if particles:
183
+ FX.burst(_entity.global_position, SP.color_of(_entity), 28, 260.0, 0.6, 5.0)
184
+ FX.burst(_entity.global_position, Color.WHITE, 10, 160.0, 0.3, 3.0)
185
+ FX.shake(shake_on_death)
186
+ FX.hitstop(hitstop_on_death)
187
+ _play(sound_death)
188
+
189
+
190
+ func _on_fired() -> void:
191
+ FX.shake(shake_on_fire)
192
+ squash_stretch(squash_amount * 0.3, 0.1)
193
+ _play(sound_fire)
194
+
195
+
196
+ func _on_collected(_by: Node) -> void:
197
+ if particles:
198
+ FX.burst(_entity.global_position, SP.color_of(_entity), 12, 150.0, 0.4, 3.0, 0.0)
199
+ var c := SP.component(_entity, &"Collectible") as CollectibleComponent
200
+ if c and c.score > 0:
201
+ FX.float_text(_entity.global_position, "+%d" % c.score, SP.color_of(_entity))
202
+ if sounds:
203
+ Sfx.play_combo(sound_collect)
204
+
205
+
206
+ func _on_projectile_expired() -> void:
207
+ if particles:
208
+ FX.burst(_entity.global_position, SP.color_of(_entity), 5, 90.0, 0.2, 2.5, 0.0)
209
+
210
+
211
+ func _on_stomped(_target: Node) -> void:
212
+ squash_stretch(-squash_amount)
213
+ FX.shake(0.25)
214
+ _play(sound_jump, 0.0, 1.25)
215
+
216
+
217
+ func _play(sound_name: String, volume_db := 0.0, pitch := 1.0) -> void:
218
+ if sounds:
219
+ Sfx.play(sound_name, pitch_variance, volume_db, pitch)
@@ -0,0 +1,63 @@
1
+ class_name PatrolAIComponent
2
+ extends Node
3
+ ## Platformer enemy brain: walks back and forth, turning at walls and (optionally) ledges.
4
+ ## Can hop periodically.
5
+
6
+ const SP_NAME := "PatrolAI"
7
+ const SP_HOSTS: Array[String] = ["CharacterBody2D"]
8
+ const SP_REQUIRES: Array[String] = ["PlatformerMovement"]
9
+ const SP_CATEGORY := "controller"
10
+
11
+ ## Initial walking direction.
12
+ @export_enum("left", "right") var start_direction: String = "left"
13
+ ## Turn around instead of walking off ledges.
14
+ @export var turn_at_ledges := true
15
+ ## Fraction of the movement max_speed to walk at.
16
+ @export_range(0.0, 1.0, 0.05) var speed_scale: float = 1.0
17
+ ## Seconds between hops (0 = never hop).
18
+ @export_range(0.0, 30.0, 0.1) var jump_interval: float = 0.0
19
+
20
+ ## Current direction: -1 left, 1 right.
21
+ var direction := -1
22
+
23
+ var _body: CharacterBody2D
24
+ var _turn_cooldown := 0.0
25
+ var _jump_timer := 0.0
26
+
27
+
28
+ func _ready() -> void:
29
+ _body = get_parent() as CharacterBody2D
30
+ direction = 1 if start_direction == "right" else -1
31
+ _jump_timer = jump_interval
32
+ process_physics_priority = -10
33
+
34
+
35
+ func _physics_process(delta: float) -> void:
36
+ var mv := SP.component(_body, &"PlatformerMovement") as PlatformerMovementComponent
37
+ if mv == null:
38
+ return
39
+ _turn_cooldown -= delta
40
+ if _turn_cooldown <= 0.0:
41
+ if _body.is_on_wall() and signf(_body.get_wall_normal().x) == -direction:
42
+ _turn()
43
+ elif turn_at_ledges and _body.is_on_floor() and not _ground_ahead():
44
+ _turn()
45
+ mv.move_vector = Vector2(direction * speed_scale, 0)
46
+ if jump_interval > 0.0:
47
+ _jump_timer -= delta
48
+ mv.jump_held = true
49
+ if _jump_timer <= 0.0 and _body.is_on_floor():
50
+ _jump_timer = jump_interval
51
+ mv.request_jump()
52
+
53
+
54
+ func _turn() -> void:
55
+ direction = -direction
56
+ _turn_cooldown = 0.2
57
+
58
+
59
+ func _ground_ahead() -> bool:
60
+ var half := SP.half_size(_body)
61
+ var from := _body.global_position + Vector2(direction * (half.x + 2.0), 0)
62
+ var q := PhysicsRayQueryParameters2D.create(from, from + Vector2(0, half.y + 12.0), SP.LAYER_WORLD, [_body.get_rid()])
63
+ return not _body.get_world_2d().direct_space_state.intersect_ray(q).is_empty()