spawnpoint-studio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spawnpoint/__init__.py +3 -0
- spawnpoint/_data/examples/coin_hopper.json +228 -0
- spawnpoint/_data/examples/neon_arena.json +251 -0
- spawnpoint/_data/examples/pixel_arena.json +242 -0
- spawnpoint/_data/examples/pixel_hopper.json +221 -0
- spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
- spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
- spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
- spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
- spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
- spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
- spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
- spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
- spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
- spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
- spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
- spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
- spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
- spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
- spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
- spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
- spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
- spawnpoint/analyze.py +387 -0
- spawnpoint/build.py +146 -0
- spawnpoint/cli.py +290 -0
- spawnpoint/cockpit/__init__.py +0 -0
- spawnpoint/cockpit/server.py +473 -0
- spawnpoint/cockpit/static/app.css +111 -0
- spawnpoint/cockpit/static/app.js +327 -0
- spawnpoint/cockpit/static/index.html +79 -0
- spawnpoint/diagnostics.py +91 -0
- spawnpoint/expr.py +219 -0
- spawnpoint/gif.py +58 -0
- spawnpoint/godot.py +107 -0
- spawnpoint/graph.py +175 -0
- spawnpoint/level.py +157 -0
- spawnpoint/llm_server.py +77 -0
- spawnpoint/manifest.py +222 -0
- spawnpoint/playtest/__init__.py +104 -0
- spawnpoint/playtest/report.py +148 -0
- spawnpoint/playtest/runner.py +51 -0
- spawnpoint/playtest/tuner.py +151 -0
- spawnpoint/project.py +94 -0
- spawnpoint/py.typed +0 -0
- spawnpoint/rules.py +310 -0
- spawnpoint/sfx.py +352 -0
- spawnpoint/sprites/__init__.py +0 -0
- spawnpoint/sprites/ai.py +222 -0
- spawnpoint/sprites/contour.py +147 -0
- spawnpoint/sprites/generate.py +507 -0
- spawnpoint/sprites/importer.py +212 -0
- spawnpoint/sprites/resolve.py +139 -0
- spawnpoint/sprites/spriteset.py +159 -0
- spawnpoint/sprites/tiles.py +102 -0
- spawnpoint/studio/__init__.py +0 -0
- spawnpoint/studio/assemble.py +528 -0
- spawnpoint/studio/brief.py +197 -0
- spawnpoint/studio/creative.py +124 -0
- spawnpoint/studio/dataset.py +136 -0
- spawnpoint/studio/edits.py +206 -0
- spawnpoint/studio/levelgen.py +352 -0
- spawnpoint/studio/llm.py +145 -0
- spawnpoint/studio/orchestrator.py +341 -0
- spawnpoint/studio/prompts.py +122 -0
- spawnpoint/studio/web.py +123 -0
- spawnpoint/studio/webcheck.py +210 -0
- spawnpoint/studio_cli.py +232 -0
- spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
- spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
- spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
- spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
- spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
- spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +49 -0
- tests/test_ai_sprites.py +122 -0
- tests/test_analyze.py +116 -0
- tests/test_cockpit.py +148 -0
- tests/test_e2e.py +33 -0
- tests/test_expr.py +59 -0
- tests/test_gif.py +19 -0
- tests/test_godot_library.py +17 -0
- tests/test_graph.py +44 -0
- tests/test_level.py +64 -0
- tests/test_live_llm.py +38 -0
- tests/test_manifest.py +54 -0
- tests/test_playtest.py +129 -0
- tests/test_rules.py +85 -0
- tests/test_sfx.py +30 -0
- tests/test_sprites.py +261 -0
- tests/test_studio.py +335 -0
- tests/test_web.py +41 -0
|
@@ -0,0 +1,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()
|