Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ Product first, then implementation:
5. [AI handoff](docs/AI_HANDOFF.md) — current state, known issues, next tasks
6. [Contributor rules](AGENTS.md) and [GitHub workflow](docs/GITHUB_WORKFLOW.md)

Art assets are governed by [docs/ART_SPEC.md](docs/ART_SPEC.md). All sprites are placeholders, marked with a magenta border.
The playable actors, projectile, combat effects, and matching HUD fields use the
production Art V2 pack. The legacy placeholder set remains as source material
for modules not yet integrated; see
[docs/ART_V2_INTEGRATION_REPORT.md](docs/ART_V2_INTEGRATION_REPORT.md).

This repository is a greybox prototype, not a finished or publicly supported game.
36 changes: 25 additions & 11 deletions actors/boss/boss.tscn
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
[gd_scene load_steps=8 format=3 uid="uid://bnnbossgate001"]
[gd_scene load_steps=10 format=3 uid="uid://bnnbossgate001"]

[ext_resource type="Script" path="res://actors/boss/boss_actor.gd" id="1_boss"]
[ext_resource type="SpriteFrames" path="res://actors/boss/boss_frames.tres" id="2_frames"]
[ext_resource type="SpriteFrames" path="res://assets_v2/godot/gate_warden_frames.tres" id="2_frames"]
[ext_resource type="Script" path="res://core/hurtbox.gd" id="3_hurtbox"]
[ext_resource type="Script" path="res://core/hitbox.gd" id="4_hitbox"]
[ext_resource type="Script" path="res://visuals/death_effect.gd" id="5_death_effect"]
[ext_resource type="Texture2D" path="res://assets_v2/effects/death_dissolve.png" id="6_death_texture"]

[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"]
size = Vector2(30, 52)
size = Vector2(54, 104)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"]
size = Vector2(38, 56)
size = Vector2(70, 120)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"]
size = Vector2(46, 40)
size = Vector2(110, 70)

[node name="Boss" type="CharacterBody2D"]
collision_layer = 4
collision_mask = 1
script = ExtResource("1_boss")

[node name="Sprite" type="AnimatedSprite2D" parent="."]
position = Vector2(0, -48)
sprite_frames = ExtResource("2_frames")
animation = &"idle"
autoplay = "idle"
animation = &"gate_warden_idle"
autoplay = "gate_warden_idle"
centered = false
offset = Vector2(-96, -176)

[node name="Body" type="CollisionShape2D" parent="."]
position = Vector2(0, -26)
position = Vector2(0, -52)
shape = SubResource("RectangleShape2D_body")

[node name="Hurtbox" type="Area2D" parent="."]
Expand All @@ -35,7 +38,7 @@ collision_mask = 0
script = ExtResource("3_hurtbox")

[node name="Shape" type="CollisionShape2D" parent="Hurtbox"]
position = Vector2(0, -28)
position = Vector2(0, -60)
shape = SubResource("RectangleShape2D_hurt")

[node name="AttackHitbox" type="Area2D" parent="."]
Expand All @@ -44,5 +47,16 @@ collision_mask = 8
script = ExtResource("4_hitbox")

[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"]
position = Vector2(34, -26)
position = Vector2(66, -58)
shape = SubResource("RectangleShape2D_hit")

[node name="DeathEffect" type="Sprite2D" parent="."]
position = Vector2(0, -88)
scale = Vector2(2, 2)
texture = ExtResource("6_death_texture")
hframes = 8
visible = false
z_index = 3
modulate = Color(1, 1, 1, 0.72)
script = ExtResource("5_death_effect")
playback_fps = 8.0
92 changes: 91 additions & 1 deletion actors/boss/boss_actor.gd
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,103 @@ extends EnemyBase

signal defeated(boss: BossActor)

const ACTIVE_FIRST_FRAME := 4
const ACTIVE_LAST_FRAME := 5

var _death_source_id: StringName = &"unknown"
var _boss_death_published: bool = false


func _ready() -> void:
super._ready()
sprite.animation_finished.connect(_on_animation_finished)
sprite.frame_changed.connect(_sync_attack_window)

func start_encounter() -> void:
EventBus.boss_started.emit(
EventBus.context({"actor_id": actor_id(), "max_hp": max_hp()})
)

func _publish_death(source_id: StringName) -> void:
# EnemyBase owns idempotent death. Completion waits for the authored
# collapse so the victory screen cannot cover its first frame.
_death_source_id = source_id


func _enter_state(next: int) -> void:
match next:
State.IDLE:
sprite.play(&"gate_warden_idle")
State.CHASE:
sprite.play(&"gate_warden_walk")
State.WINDUP:
attack_hitbox.set_active(false)
sprite.modulate = Color.WHITE
sprite.play(&"gate_warden_attack_1")
sprite.frame = 0
State.ATTACK:
_sync_attack_window()
State.RECOVER:
attack_hitbox.set_active(false)
State.HURT:
attack_hitbox.set_active(false)
sprite.play(&"gate_warden_hurt")
sprite.frame = 0
State.DEAD:
attack_hitbox.set_active(false)
sprite.play(&"gate_warden_death")
sprite.frame = 0


func _exit_state(_previous: int) -> void:
attack_hitbox.set_active(false)


func _update_state(delta: float) -> void:
match _state:
State.IDLE:
_decelerate(delta)
if _target_in_range(config.detection_range):
change_state(State.CHASE)
State.CHASE:
_chase(delta)
State.WINDUP:
_decelerate(delta)
_face_target()
if sprite.frame >= ACTIVE_FIRST_FRAME:
change_state(State.ATTACK)
State.ATTACK:
_decelerate(delta)
if sprite.frame > ACTIVE_LAST_FRAME:
change_state(State.RECOVER)
State.RECOVER:
_decelerate(delta)
if not sprite.is_playing():
_cooldown = config.attack_cooldown
change_state(State.IDLE)
State.HURT:
_decelerate(delta, 400.0)
if not sprite.is_playing():
change_state(State.CHASE if _target_in_range(config.detection_range) else State.IDLE)
State.DEAD:
pass


func _sync_attack_window() -> void:
var active := (
not _is_dead
and sprite.animation == &"gate_warden_attack_1"
and sprite.frame >= ACTIVE_FIRST_FRAME
and sprite.frame <= ACTIVE_LAST_FRAME
)
attack_hitbox.set_active(active)


func _on_animation_finished() -> void:
if _state != State.DEAD or _boss_death_published:
return
_boss_death_published = true
EventBus.boss_died.emit(
EventBus.context({"actor_id": actor_id(), "source_id": source_id})
EventBus.context({"actor_id": actor_id(), "source_id": _death_source_id})
)
defeated.emit(self)
37 changes: 31 additions & 6 deletions actors/enemies/ghost_archer.gd
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ signal arrow_fired(arrow: GhostArrow)

var _patrol_origin_x: float
var _patrol_direction: int = 1
var _arrow_released: bool = false


func _ready() -> void:
Expand All @@ -25,10 +26,28 @@ func initialize(enemy_config: EnemyConfig, target: Node2D) -> void:


func _enter_state(next: int) -> void:
super._enter_state(next)
if next == State.ATTACK:
attack_hitbox.set_active(false)
_shoot()
attack_hitbox.set_active(false)
match next:
State.IDLE:
sprite.play(&"ghost_archer_idle")
State.CHASE:
sprite.play(&"ghost_archer_retreat")
State.WINDUP:
sprite.modulate = Color.WHITE
sprite.play(&"ghost_archer_aim")
sprite.frame = 0
State.ATTACK:
_arrow_released = false
sprite.play(&"ghost_archer_shoot")
sprite.frame = 0
State.RECOVER:
pass
State.HURT:
sprite.play(&"ghost_archer_hurt")
sprite.frame = 0
State.DEAD:
sprite.play(&"ghost_archer_death")
sprite.frame = 0


func _update_state(delta: float) -> void:
Expand All @@ -42,11 +61,17 @@ func _update_state(delta: float) -> void:
State.WINDUP:
_decelerate(delta)
_face_target()
if _state_elapsed >= config.attack_windup:
# frame_map.json: the bow is fully drawn on aim frame 5.
if sprite.frame >= 5 or not sprite.is_playing():
change_state(State.ATTACK)
State.ATTACK:
_decelerate(delta)
if _state_elapsed >= config.attack_active:
_face_target()
# frame_map.json: release exactly on shoot frame 1.
if not _arrow_released and sprite.frame >= 1:
_arrow_released = true
_shoot()
if not sprite.is_playing():
change_state(State.RECOVER)
State.RECOVER:
_decelerate(delta)
Expand Down
41 changes: 37 additions & 4 deletions actors/enemies/ghost_archer.tscn
Original file line number Diff line number Diff line change
@@ -1,18 +1,51 @@
[gd_scene load_steps=5 format=3]
[gd_scene load_steps=10 format=3]

[ext_resource type="PackedScene" path="res://actors/enemies/enemy_base.tscn" id="1_base"]
[ext_resource type="Script" path="res://actors/enemies/ghost_archer.gd" id="2_script"]
[ext_resource type="SpriteFrames" path="res://actors/enemies/ghost_archer_frames.tres" id="3_frames"]
[ext_resource type="SpriteFrames" path="res://assets_v2/godot/ghost_archer_frames.tres" id="3_frames"]
[ext_resource type="Resource" path="res://data/actors/ghost_archer_tactics.tres" id="4_tactics"]
[ext_resource type="Script" path="res://visuals/death_effect.gd" id="5_death_effect"]
[ext_resource type="Texture2D" path="res://assets_v2/effects/death_dissolve.png" id="6_death_texture"]

[sub_resource type="RectangleShape2D" id="RectangleShape2D_v2_body"]
size = Vector2(20, 48)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_v2_hurt"]
size = Vector2(26, 56)

[sub_resource type="RectangleShape2D" id="RectangleShape2D_v2_hit"]
size = Vector2(1, 1)

[node name="GhostArcher" instance=ExtResource("1_base")]
script = ExtResource("2_script")
archer_config = ExtResource("4_tactics")

[node name="Sprite" parent="." index="0"]
sprite_frames = ExtResource("3_frames")
animation = &"idle"
autoplay = "idle"
animation = &"ghost_archer_idle"
autoplay = "ghost_archer_idle"
position = Vector2(0, 0)
centered = false
offset = Vector2(-48, -88)

[node name="Body" parent="." index="1"]
position = Vector2(0, -24)
shape = SubResource("RectangleShape2D_v2_body")

[node name="Shape" parent="Hurtbox" index="0"]
position = Vector2(0, -28)
shape = SubResource("RectangleShape2D_v2_hurt")

[node name="Shape" parent="AttackHitbox" index="0"]
disabled = true
shape = SubResource("RectangleShape2D_v2_hit")

[node name="DeathEffect" type="Sprite2D" parent="." index="4"]
position = Vector2(0, -44)
texture = ExtResource("6_death_texture")
hframes = 8
visible = false
z_index = 3
modulate = Color(1, 1, 1, 0.72)
script = ExtResource("5_death_effect")
playback_fps = 8.0
9 changes: 9 additions & 0 deletions actors/enemies/ghost_arrow.gd
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ var _configured: bool = false
func _ready() -> void:
area_entered.connect(_on_area_entered)
body_entered.connect(_on_body_entered)
var visual := get_node_or_null(^"Visual") as Sprite2D
var fallback := get_node_or_null(^"FallbackVisual") as CanvasItem
if fallback != null:
fallback.visible = visual == null or visual.texture == null


func configure(
Expand Down Expand Up @@ -54,6 +58,11 @@ func has_impacted() -> bool:
return _has_impacted


func uses_v2_texture() -> bool:
var visual := get_node_or_null(^"Visual") as Sprite2D
return visual != null and visual.texture != null


func _on_area_entered(area: Area2D) -> void:
if _has_impacted or not (area is Hurtbox):
return
Expand Down
7 changes: 6 additions & 1 deletion actors/enemies/ghost_arrow.tscn
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[gd_scene load_steps=3 format=3]
[gd_scene load_steps=4 format=3]

[ext_resource type="Script" path="res://actors/enemies/ghost_arrow.gd" id="1_arrow"]
[ext_resource type="Texture2D" path="res://assets_v2/projectiles/ghost_fire_arrow.png" id="2_arrow_texture"]

[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_arrow"]
radius = 2.0
Expand All @@ -11,9 +12,13 @@ collision_layer = 64
collision_mask = 9
script = ExtResource("1_arrow")

[node name="Visual" type="Sprite2D" parent="."]
texture = ExtResource("2_arrow_texture")

[node name="FallbackVisual" type="Polygon2D" parent="."]
polygon = PackedVector2Array(-7, -1, 4, -1, 4, -3, 8, 0, 4, 3, 4, 1, -7, 1)
color = Color(0.31, 0.88, 0.65, 0.9)
visible = false

[node name="Shape" type="CollisionShape2D" parent="."]
rotation = 1.5708
Expand Down
Loading
Loading