forked from wxm/Fighting_Rthythm_game
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a451041be4 | ||
|
|
87d7533b4a | ||
|
|
e62ed84518 | ||
|
|
fc941cf08d | ||
|
|
67db812de4 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
.godot/
|
||||||
BIN
addons/.DS_Store
vendored
BIN
addons/.DS_Store
vendored
Binary file not shown.
BIN
addons/godot_mcp/.DS_Store
vendored
BIN
addons/godot_mcp/.DS_Store
vendored
Binary file not shown.
BIN
assets/.DS_Store
vendored
BIN
assets/.DS_Store
vendored
Binary file not shown.
BIN
assets/art/.DS_Store
vendored
BIN
assets/art/.DS_Store
vendored
Binary file not shown.
BIN
assets/art/characters/player_punch.png
Normal file
BIN
assets/art/characters/player_punch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
40
assets/art/characters/player_punch.png.import
Normal file
40
assets/art/characters/player_punch.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://c3okawgkks4td"
|
||||||
|
path="res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/art/characters/player_punch.png"
|
||||||
|
dest_files=["res://.godot/imported/player_punch.png-14a58131e3712983546ba83347b7e913.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/art/characters/warrior_man_sheet.png
Normal file
BIN
assets/art/characters/warrior_man_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
40
assets/art/characters/warrior_man_sheet.png.import
Normal file
40
assets/art/characters/warrior_man_sheet.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://bbkamgcdsw5g6"
|
||||||
|
path="res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/art/characters/warrior_man_sheet.png"
|
||||||
|
dest_files=["res://.godot/imported/warrior_man_sheet.png-5133318205f8c6ca88007088752e7f76.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/art/characters/warrior_woman_sheet.png
Normal file
BIN
assets/art/characters/warrior_woman_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
40
assets/art/characters/warrior_woman_sheet.png.import
Normal file
40
assets/art/characters/warrior_woman_sheet.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://womoel71g8ae"
|
||||||
|
path="res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/art/characters/warrior_woman_sheet.png"
|
||||||
|
dest_files=["res://.godot/imported/warrior_woman_sheet.png-fd3d48dc9f47dfc48f7d4efc61b9be9c.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/art/effects/effect_hp_mp_sheet.png
Normal file
BIN
assets/art/effects/effect_hp_mp_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 738 B |
40
assets/art/effects/effect_hp_mp_sheet.png.import
Normal file
40
assets/art/effects/effect_hp_mp_sheet.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://1p2uqgg1jole"
|
||||||
|
path="res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/art/effects/effect_hp_mp_sheet.png"
|
||||||
|
dest_files=["res://.godot/imported/effect_hp_mp_sheet.png-9328f2d19fa99f3dd1de40f54cc78b6d.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
BIN
assets/art/effects/effect_sheet.png
Normal file
BIN
assets/art/effects/effect_sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 625 B |
40
assets/art/effects/effect_sheet.png.import
Normal file
40
assets/art/effects/effect_sheet.png.import
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dgpl0g56pw1qu"
|
||||||
|
path="res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/art/effects/effect_sheet.png"
|
||||||
|
dest_files=["res://.godot/imported/effect_sheet.png-cf8021daa1eb789ae18fad86613dfb66.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
68
autoload/combat_manager.gd
Normal file
68
autoload/combat_manager.gd
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_damage(base_attack: float, action: Resource, judgement: Dictionary, buffs: Variant = null, burst: Variant = null) -> float:
|
||||||
|
var action_mult := _resource_float(action, "damage_mult", 1.0)
|
||||||
|
var judgement_mult := _judgement_mult(judgement)
|
||||||
|
var buff_mult := _provider_mult(buffs, "damage_mult", action)
|
||||||
|
var burst_mult := _provider_mult(burst, "damage_mult", action)
|
||||||
|
return base_attack * action_mult * judgement_mult * buff_mult * burst_mult
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_cost(action: Resource, burst: Variant = null) -> float:
|
||||||
|
var base_cost := _resource_float(action, "base_cost", 0.0)
|
||||||
|
var cost := base_cost if base_cost > 0.0 else _resource_float(action, "energy_cost", 0.0)
|
||||||
|
var burst_mult := _provider_mult(burst, "cost_mult", action)
|
||||||
|
return maxf(0.0, cost * burst_mult)
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_move(action: Resource, judgement: Dictionary, burst: Variant = null) -> Vector2:
|
||||||
|
var judgement_mult := _dict_float(judgement, "move_mult", 1.0)
|
||||||
|
var burst_mult := _provider_mult(burst, "move_mult", action)
|
||||||
|
return Vector2(
|
||||||
|
_resource_float(action, "move_mult_x", 0.0),
|
||||||
|
_resource_float(action, "move_mult_y", 0.0)
|
||||||
|
) * judgement_mult * burst_mult
|
||||||
|
|
||||||
|
|
||||||
|
func _judgement_damage_mult(label: StringName) -> float:
|
||||||
|
match label:
|
||||||
|
&"perfect":
|
||||||
|
return 1.25
|
||||||
|
&"good":
|
||||||
|
return 1.0
|
||||||
|
&"bad":
|
||||||
|
return 0.75
|
||||||
|
_:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _judgement_mult(judgement: Dictionary) -> float:
|
||||||
|
if judgement.has("damage_mult"):
|
||||||
|
return float(judgement["damage_mult"])
|
||||||
|
if judgement.has("label"):
|
||||||
|
return _judgement_damage_mult(StringName(str(judgement["label"])))
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _resource_float(resource: Resource, property_name: String, fallback: float) -> float:
|
||||||
|
if resource == null:
|
||||||
|
return fallback
|
||||||
|
var value = resource.get(property_name)
|
||||||
|
if value == null:
|
||||||
|
return fallback
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
func _dict_float(values: Dictionary, key: String, fallback: float) -> float:
|
||||||
|
if not values.has(key):
|
||||||
|
return fallback
|
||||||
|
return float(values[key])
|
||||||
|
|
||||||
|
|
||||||
|
func _provider_mult(provider, method_name: String, action: Resource) -> float:
|
||||||
|
if provider == null:
|
||||||
|
return 1.0
|
||||||
|
if provider.has_method(method_name):
|
||||||
|
return float(provider.call(method_name, action))
|
||||||
|
return 1.0
|
||||||
1
autoload/combat_manager.gd.uid
Normal file
1
autoload/combat_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dmeiefmd38a30
|
||||||
19
autoload/event_bus.gd
Normal file
19
autoload/event_bus.gd
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
signal rhythm_action_requested(action_name: StringName)
|
||||||
|
signal beat_ticked(beat_index: int)
|
||||||
|
signal judgement_made(quality: StringName, offset_ms: float)
|
||||||
|
signal action_judged(action_name: StringName, rating: Dictionary)
|
||||||
|
signal chart_event_upcoming(event: Resource, time_to_event: float)
|
||||||
|
signal chart_event_triggered(event: Resource)
|
||||||
|
signal chart_reset(chart_id: StringName)
|
||||||
|
|
||||||
|
signal skill_executed(skill: Resource, judgement: StringName)
|
||||||
|
signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2)
|
||||||
|
signal damage_dealt(target: Node, amount: int, hit_type: StringName)
|
||||||
|
|
||||||
|
signal player_health_changed(current: int, max_value: int)
|
||||||
|
signal player_energy_changed(current: float, max_value: float)
|
||||||
|
signal player_charge_changed(current: float, max_value: float, ready: bool, active: bool)
|
||||||
|
signal combo_updated(inputs: Array[StringName])
|
||||||
|
signal combo_cleared(reason: StringName)
|
||||||
1
autoload/event_bus.gd.uid
Normal file
1
autoload/event_bus.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cpgixq8ibqhh4
|
||||||
167
autoload/rhythm_manager.gd
Normal file
167
autoload/rhythm_manager.gd
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
extends AudioStreamPlayer
|
||||||
|
|
||||||
|
signal beat_ticked(beat_index: int)
|
||||||
|
signal judgement_made(quality: StringName, offset_ms: float)
|
||||||
|
signal action_judged(action_name: StringName, rating: Dictionary)
|
||||||
|
|
||||||
|
@export var bpm: float = 120.0:
|
||||||
|
set(value):
|
||||||
|
bpm = maxf(1.0, value)
|
||||||
|
beat_time = 60.0 / bpm
|
||||||
|
@export var measures := 4
|
||||||
|
@export var beat_offset := 0.0
|
||||||
|
@export var perfect_window := 0.060
|
||||||
|
@export var good_window := 0.120
|
||||||
|
@export var bad_window := 0.200
|
||||||
|
@export var judgement_scale := 1.0
|
||||||
|
@export var starts_on_ready := true
|
||||||
|
|
||||||
|
const DEFAULT_STREAM_PATH := "res://assets/audio/song.ogg"
|
||||||
|
|
||||||
|
var beat_time := 0.5
|
||||||
|
var beat_index := 0
|
||||||
|
var running := false
|
||||||
|
|
||||||
|
var _start_time_usec := 0
|
||||||
|
var _last_reported_beat := -1
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if stream == null and DisplayServer.get_name() != "headless":
|
||||||
|
stream = load(DEFAULT_STREAM_PATH)
|
||||||
|
volume_db = -10.0
|
||||||
|
beat_time = 60.0 / maxf(1.0, bpm)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null and not bus.is_connected("rhythm_action_requested", _on_rhythm_action_requested):
|
||||||
|
bus.connect("rhythm_action_requested", _on_rhythm_action_requested)
|
||||||
|
if starts_on_ready:
|
||||||
|
start()
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if not running:
|
||||||
|
return
|
||||||
|
var adjusted_position := _apply_beat_offset(song_position())
|
||||||
|
beat_index = int(floor(adjusted_position / beat_time))
|
||||||
|
if _last_reported_beat < beat_index:
|
||||||
|
_last_reported_beat = beat_index
|
||||||
|
beat_ticked.emit(beat_index)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("beat_ticked", beat_index)
|
||||||
|
|
||||||
|
|
||||||
|
func configure(next_bpm: float, next_measures: int, next_beat_offset: float, next_windows := Vector3(0.060, 0.120, 0.200)) -> void:
|
||||||
|
bpm = next_bpm
|
||||||
|
measures = next_measures
|
||||||
|
beat_offset = next_beat_offset
|
||||||
|
perfect_window = next_windows.x
|
||||||
|
good_window = next_windows.y
|
||||||
|
bad_window = next_windows.z
|
||||||
|
|
||||||
|
|
||||||
|
func start() -> void:
|
||||||
|
running = true
|
||||||
|
_start_time_usec = Time.get_ticks_usec()
|
||||||
|
beat_index = 0
|
||||||
|
_last_reported_beat = -1
|
||||||
|
if stream != null and not playing:
|
||||||
|
play()
|
||||||
|
|
||||||
|
|
||||||
|
func stop_manager() -> void:
|
||||||
|
if playing:
|
||||||
|
stop()
|
||||||
|
running = false
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
if playing:
|
||||||
|
stop()
|
||||||
|
stream = null
|
||||||
|
|
||||||
|
|
||||||
|
func song_position() -> float:
|
||||||
|
if running and playing:
|
||||||
|
var current_position := get_playback_position() + AudioServer.get_time_since_last_mix()
|
||||||
|
current_position -= AudioServer.get_output_latency()
|
||||||
|
return maxf(0.0, current_position)
|
||||||
|
if running:
|
||||||
|
return float(Time.get_ticks_usec() - _start_time_usec) / 1000000.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func judge(input_timestamp_ms: float) -> Dictionary:
|
||||||
|
return get_rating_for_time(input_timestamp_ms / 1000.0)
|
||||||
|
|
||||||
|
|
||||||
|
func judge_action(action_name: StringName) -> Dictionary:
|
||||||
|
var rating := get_current_rating()
|
||||||
|
rating["action"] = action_name
|
||||||
|
action_judged.emit(action_name, rating)
|
||||||
|
judgement_made.emit(StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("judgement_made", StringName(str(rating.get("label", "miss"))), float(rating.get("diff", INF)) * 1000.0)
|
||||||
|
bus.emit_signal("action_judged", action_name, rating)
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
func get_current_rating() -> Dictionary:
|
||||||
|
return get_rating_for_time(song_position())
|
||||||
|
|
||||||
|
|
||||||
|
func get_rating_for_time(time_seconds: float) -> Dictionary:
|
||||||
|
var adjusted_time := _apply_beat_offset(time_seconds)
|
||||||
|
if adjusted_time < 0.0:
|
||||||
|
return _rating_result(&"miss", Color("ff0055"), 0, 0.0, INF, INF)
|
||||||
|
|
||||||
|
var nearest_beat := int(round(adjusted_time / beat_time))
|
||||||
|
var nearest_beat_time := nearest_beat * beat_time
|
||||||
|
var diff := adjusted_time - nearest_beat_time
|
||||||
|
var abs_diff := absf(diff)
|
||||||
|
var scale := maxf(0.01, judgement_scale)
|
||||||
|
|
||||||
|
if abs_diff <= perfect_window * scale:
|
||||||
|
return _rating_result(&"perfect", Color("00f2ff"), nearest_beat, nearest_beat_time, diff, abs_diff)
|
||||||
|
if abs_diff <= good_window * scale:
|
||||||
|
return _rating_result(&"good", Color("ffffff"), nearest_beat, nearest_beat_time, diff, abs_diff)
|
||||||
|
if abs_diff <= bad_window * scale:
|
||||||
|
return _rating_result(&"bad", Color("ffaa00"), nearest_beat, nearest_beat_time, diff, abs_diff)
|
||||||
|
return _rating_result(&"miss", Color("ff0055"), nearest_beat, nearest_beat_time, diff, abs_diff)
|
||||||
|
|
||||||
|
|
||||||
|
func get_current_beat_progress() -> float:
|
||||||
|
return get_beat_progress_for_time(song_position())
|
||||||
|
|
||||||
|
|
||||||
|
func get_beat_progress_for_time(time_seconds: float) -> float:
|
||||||
|
var adjusted_time := _apply_beat_offset(time_seconds)
|
||||||
|
if adjusted_time < 0.0:
|
||||||
|
return 0.0
|
||||||
|
return fposmod(adjusted_time, beat_time) / beat_time
|
||||||
|
|
||||||
|
|
||||||
|
func _on_rhythm_action_requested(action_name: StringName) -> void:
|
||||||
|
judge_action(action_name)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_beat_offset(time_seconds: float) -> float:
|
||||||
|
return time_seconds + beat_offset
|
||||||
|
|
||||||
|
|
||||||
|
func _rating_result(label: StringName, color: Color, nearest_beat: int, nearest_beat_time: float, diff: float, abs_diff: float) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"label": str(label),
|
||||||
|
"color": color,
|
||||||
|
"nearest_beat": nearest_beat,
|
||||||
|
"nearest_beat_time": nearest_beat_time,
|
||||||
|
"diff": diff,
|
||||||
|
"abs_diff": abs_diff,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("EventBus")
|
||||||
1
autoload/rhythm_manager.gd.uid
Normal file
1
autoload/rhythm_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://hoga4p3vm5qp
|
||||||
1319
docs/2026-07-02-action-intent-phase-fix-plan.md
Normal file
1319
docs/2026-07-02-action-intent-phase-fix-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
992
docs/superpowers/plans/2026-07-02-chart-layer.md
Normal file
992
docs/superpowers/plans/2026-07-02-chart-layer.md
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
# Chart Layer Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a thin, testable Chart Layer that turns beat-indexed battle data into upcoming and triggered chart events for UI, enemies, hazards, and future boss patterns.
|
||||||
|
|
||||||
|
**Architecture:** Keep `RhythmManager` as the only music clock and rhythm judgement service. Add chart resources plus a scene-owned `ChartRunner` that reads `RhythmManager.song_position()`, emits chart events once, and mirrors those events through `EventBus` for UI/world listeners. Do not make `ChartRunner` an autoload; a chart belongs to a song, room, or encounter, not to the whole app.
|
||||||
|
|
||||||
|
**Tech Stack:** Godot 4.6, GDScript, Resource `.tres` data model, existing `EventBus`, existing `RhythmManager`, existing headless Godot test scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Project Reading
|
||||||
|
|
||||||
|
The project already has a good player-side rhythm action foundation:
|
||||||
|
|
||||||
|
- `RhythmManager` is an autoload clock and judgement provider: `autoload/rhythm_manager.gd`.
|
||||||
|
- `EventBus` broadcasts rhythm, judgement, skill, projectile, damage, player resource, and combo UI events: `autoload/event_bus.gd`.
|
||||||
|
- `InputComponent` creates timestamped `InputIntent` objects: `scenes/components/input_component.gd`.
|
||||||
|
- `ActionController` owns intent judgement, combo recording, action phase timing, pending intent replacement, and startup/active/recovery flow: `scenes/components/action_controller.gd`.
|
||||||
|
- `ComboWindow` keeps explicit inputs and explicit `Ø` Miss placeholders; it does not auto-fill empty beats: `scenes/components/combo_window.gd`.
|
||||||
|
- `ActionData` resources and `ActionResolver` already make player actions data-driven: `resources/action_data.gd`, `resources/actions/*.tres`, `scenes/combat/action_resolver.gd`.
|
||||||
|
- `RhythmTrack` currently reacts to `beat_ticked` and `action_judged`, but it has no knowledge of future chart events: `scenes/ui/rhythm_track.gd`.
|
||||||
|
- `ActorsContainer` currently manages spawned projectiles only; there is not yet a real enemy container or enemy behavior layer: `scenes/stage/actors_container.gd`.
|
||||||
|
|
||||||
|
The Chart Layer should therefore avoid touching the player input/action pipeline. It should add a parallel world-timeline pipeline.
|
||||||
|
|
||||||
|
## The Actual Problem Chart Layer Solves
|
||||||
|
|
||||||
|
Right now the project has a clock, but not a battle timeline.
|
||||||
|
|
||||||
|
Current flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RhythmManager
|
||||||
|
-> beat_ticked(beat_index)
|
||||||
|
-> UI / BurstComponent / future enemies listen directly
|
||||||
|
```
|
||||||
|
|
||||||
|
This works while the game is only a player combo sandbox. It becomes fragile when enemies, hazards, boss patterns, camera hits, accents, and tutorial prompts need to happen on specific beats.
|
||||||
|
|
||||||
|
Without Chart Layer, each future system will likely write its own beat math:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _on_beat_ticked(beat_index: int) -> void:
|
||||||
|
if beat_index % 4 == 2:
|
||||||
|
show_warning()
|
||||||
|
elif beat_index % 4 == 3:
|
||||||
|
attack()
|
||||||
|
```
|
||||||
|
|
||||||
|
That creates four problems:
|
||||||
|
|
||||||
|
1. **Battle timing is scattered.** Enemy scripts, UI scripts, stage scripts, and boss scripts all become tiny schedule owners.
|
||||||
|
2. **UI cannot reliably preview future danger.** A `beat_ticked` signal tells listeners what just happened, not what will happen one beat from now.
|
||||||
|
3. **Warning and attack can drift apart.** If UI and enemy each compute their own beat offsets, one can show a warning while another opens hitboxes on a different beat.
|
||||||
|
4. **Encounter tuning becomes code editing.** Changing "attack at beat 16" to "warning at 15.5, attack at 16, recover at 17" should be data work, not enemy-script surgery.
|
||||||
|
|
||||||
|
Chart Layer centralizes that schedule.
|
||||||
|
|
||||||
|
New flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RhythmManager.song_position()
|
||||||
|
-> ChartRunner.update_for_song_time(song_time)
|
||||||
|
-> BeatChart / ChartTrack / ChartEvent
|
||||||
|
-> chart_event_upcoming(event, time_to_event)
|
||||||
|
-> chart_event_triggered(event)
|
||||||
|
-> EventBus mirrors both signals
|
||||||
|
-> RhythmTrack / EnemyBeatPlanner / Hazard / Camera listen
|
||||||
|
```
|
||||||
|
|
||||||
|
Player input remains independent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
InputIntent
|
||||||
|
-> RhythmManager.judge(timestamp)
|
||||||
|
-> ActionController
|
||||||
|
-> ComboWindow
|
||||||
|
-> ActionResolver
|
||||||
|
-> ActionExecutor
|
||||||
|
```
|
||||||
|
|
||||||
|
Chart Layer must not force the player to press a specific key on a specific beat.
|
||||||
|
|
||||||
|
## Scope for This Plan
|
||||||
|
|
||||||
|
This plan implements the smallest useful Chart Layer:
|
||||||
|
|
||||||
|
- Data resources:
|
||||||
|
- `ChartEvent`
|
||||||
|
- `ChartTrack`
|
||||||
|
- `BeatChart`
|
||||||
|
- Runtime node:
|
||||||
|
- `ChartRunner`
|
||||||
|
- Event bus signals:
|
||||||
|
- `chart_event_upcoming`
|
||||||
|
- `chart_event_triggered`
|
||||||
|
- `chart_reset`
|
||||||
|
- Main scene integration:
|
||||||
|
- A `ChartRunner` child under `Main`
|
||||||
|
- UI integration:
|
||||||
|
- `RhythmTrack` listens to upcoming/triggered chart events and can render simple future markers.
|
||||||
|
- Tests:
|
||||||
|
- Resource loading
|
||||||
|
- event ordering
|
||||||
|
- upcoming events fire once
|
||||||
|
- triggered events fire once
|
||||||
|
- EventBus mirrors runner events
|
||||||
|
- existing player rhythm/action tests still pass
|
||||||
|
|
||||||
|
This plan intentionally does not implement a full enemy AI system. It creates event hooks that a future `EnemyBeatPlanner` can consume.
|
||||||
|
|
||||||
|
## Event Model
|
||||||
|
|
||||||
|
First version event types:
|
||||||
|
|
||||||
|
```text
|
||||||
|
show_accent_marker
|
||||||
|
enemy_prepare_attack
|
||||||
|
enemy_attack_active
|
||||||
|
enemy_recovery
|
||||||
|
camera_pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
Event payload examples:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
{
|
||||||
|
"lane": "enemy",
|
||||||
|
"color": "ff3355",
|
||||||
|
"label": "ATK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
{
|
||||||
|
"shake": 0.35,
|
||||||
|
"duration_beats": 0.25
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended first test chart:
|
||||||
|
|
||||||
|
```text
|
||||||
|
beat 4 show_accent_marker
|
||||||
|
beat 6 enemy_prepare_attack target_id=test_enemy lead_beats=1
|
||||||
|
beat 7 enemy_attack_active target_id=test_enemy lead_beats=1
|
||||||
|
beat 8 enemy_recovery target_id=test_enemy lead_beats=0.5
|
||||||
|
beat 12 camera_pulse
|
||||||
|
beat 16 show_accent_marker
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Create:
|
||||||
|
|
||||||
|
- `resources/chart_event.gd`
|
||||||
|
One scheduled event on a beat or subdivision.
|
||||||
|
|
||||||
|
- `resources/chart_track.gd`
|
||||||
|
A named collection of related events, such as `accent`, `enemy`, `camera`, or `hazard`.
|
||||||
|
|
||||||
|
- `resources/beat_chart.gd`
|
||||||
|
A chart resource that owns tracks and exposes all events in sorted order.
|
||||||
|
|
||||||
|
- `scenes/chart/chart_runner.gd`
|
||||||
|
Scene-owned runner that reads the current song time and emits upcoming/triggered events.
|
||||||
|
|
||||||
|
- `tests/test_chart_layer.gd`
|
||||||
|
Headless test for resources, runner timing, and EventBus mirroring.
|
||||||
|
|
||||||
|
Modify:
|
||||||
|
|
||||||
|
- `autoload/event_bus.gd`
|
||||||
|
Add chart signals.
|
||||||
|
|
||||||
|
- `scenes/main/main.tscn`
|
||||||
|
Add a `ChartRunner` node as a sibling of `Stage` and `UI`.
|
||||||
|
|
||||||
|
- `scenes/ui/rhythm_track.gd`
|
||||||
|
Listen to chart events and maintain simple future markers.
|
||||||
|
|
||||||
|
- `scenes/ui/rhythm_track.tscn`
|
||||||
|
Add a `ChartMarkerContainer` node under `RhythmTrack`.
|
||||||
|
|
||||||
|
- `tests/test_rhythm_action_architecture.gd`
|
||||||
|
Add architecture assertions that the chart resource scripts and runner load.
|
||||||
|
|
||||||
|
Optional runtime data after the scripts compile:
|
||||||
|
|
||||||
|
- `resources/charts/test_song_chart.tres`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Failing Chart Layer Architecture Test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_chart_layer.gd`
|
||||||
|
- Modify: `tests/test_rhythm_action_architecture.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the focused chart test**
|
||||||
|
|
||||||
|
Create `tests/test_chart_layer.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
await process_frame
|
||||||
|
_check_resources_load()
|
||||||
|
await _check_runner_upcoming_and_triggered_once()
|
||||||
|
await _check_event_bus_mirroring()
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _check_resources_load() -> void:
|
||||||
|
_expect(load("res://resources/chart_event.gd") != null, "ChartEvent script should load")
|
||||||
|
_expect(load("res://resources/chart_track.gd") != null, "ChartTrack script should load")
|
||||||
|
_expect(load("res://resources/beat_chart.gd") != null, "BeatChart script should load")
|
||||||
|
_expect(load("res://scenes/chart/chart_runner.gd") != null, "ChartRunner script should load")
|
||||||
|
|
||||||
|
|
||||||
|
func _make_event(beat: int, event_type: StringName, target_id := &"", lead_beats := 1.0):
|
||||||
|
var event_script: Script = load("res://resources/chart_event.gd")
|
||||||
|
var event: Resource = event_script.new()
|
||||||
|
event.set("beat_index", beat)
|
||||||
|
event.set("event_type", event_type)
|
||||||
|
event.set("target_id", target_id)
|
||||||
|
event.set("lead_beats", lead_beats)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
func _make_chart() -> Resource:
|
||||||
|
var chart_script: Script = load("res://resources/beat_chart.gd")
|
||||||
|
var track_script: Script = load("res://resources/chart_track.gd")
|
||||||
|
var chart: Resource = chart_script.new()
|
||||||
|
var track: Resource = track_script.new()
|
||||||
|
chart.set("chart_id", &"test_chart")
|
||||||
|
track.set("track_id", &"enemy")
|
||||||
|
track.set("track_type", &"enemy")
|
||||||
|
track.set("events", [
|
||||||
|
_make_event(2, &"enemy_prepare_attack", &"test_enemy", 1.0),
|
||||||
|
_make_event(3, &"enemy_attack_active", &"test_enemy", 1.0),
|
||||||
|
])
|
||||||
|
chart.set("tracks", [track])
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
func _make_runner(chart: Resource) -> Node:
|
||||||
|
var runner_script: Script = load("res://scenes/chart/chart_runner.gd")
|
||||||
|
var runner: Node = runner_script.new()
|
||||||
|
runner.set("beat_time_override", 0.5)
|
||||||
|
runner.call("set_chart", chart)
|
||||||
|
root.add_child(runner)
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
|
func _check_runner_upcoming_and_triggered_once() -> void:
|
||||||
|
var runner := _make_runner(_make_chart())
|
||||||
|
var upcoming: Array[StringName] = []
|
||||||
|
var triggered: Array[StringName] = []
|
||||||
|
runner.connect("chart_event_upcoming", func(event: Resource, _time_to_event: float) -> void:
|
||||||
|
upcoming.append(StringName(str(event.get("event_type"))))
|
||||||
|
)
|
||||||
|
runner.connect("chart_event_triggered", func(event: Resource) -> void:
|
||||||
|
triggered.append(StringName(str(event.get("event_type"))))
|
||||||
|
)
|
||||||
|
|
||||||
|
runner.call("update_for_song_time", 0.49)
|
||||||
|
_expect(upcoming == [&"enemy_prepare_attack"], "Prepare upcoming should fire at lead window")
|
||||||
|
_expect(triggered.is_empty(), "No event should trigger before event time")
|
||||||
|
|
||||||
|
runner.call("update_for_song_time", 1.0)
|
||||||
|
runner.call("update_for_song_time", 1.1)
|
||||||
|
_expect(triggered == [&"enemy_prepare_attack"], "Prepare triggered should fire once")
|
||||||
|
|
||||||
|
runner.call("update_for_song_time", 1.49)
|
||||||
|
runner.call("update_for_song_time", 1.50)
|
||||||
|
runner.call("update_for_song_time", 1.80)
|
||||||
|
_expect(upcoming.count(&"enemy_attack_active") == 1, "Attack upcoming should fire once")
|
||||||
|
_expect(triggered.count(&"enemy_attack_active") == 1, "Attack triggered should fire once")
|
||||||
|
runner.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _check_event_bus_mirroring() -> void:
|
||||||
|
var bus_script: Script = load("res://autoload/event_bus.gd")
|
||||||
|
var bus: Node = bus_script.new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
|
||||||
|
var mirrored_upcoming := 0
|
||||||
|
var mirrored_triggered := 0
|
||||||
|
bus.connect("chart_event_upcoming", func(_event: Resource, _time_to_event: float) -> void:
|
||||||
|
mirrored_upcoming += 1
|
||||||
|
)
|
||||||
|
bus.connect("chart_event_triggered", func(_event: Resource) -> void:
|
||||||
|
mirrored_triggered += 1
|
||||||
|
)
|
||||||
|
|
||||||
|
var runner := _make_runner(_make_chart())
|
||||||
|
runner.call("update_for_song_time", 0.49)
|
||||||
|
runner.call("update_for_song_time", 1.0)
|
||||||
|
_expect(mirrored_upcoming == 1, "ChartRunner should mirror upcoming events to EventBus")
|
||||||
|
_expect(mirrored_triggered == 1, "ChartRunner should mirror triggered events to EventBus")
|
||||||
|
|
||||||
|
runner.queue_free()
|
||||||
|
bus.queue_free()
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
|
func _expect(condition: bool, label: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("PASS chart layer")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for failure: String in failures:
|
||||||
|
push_error(failure)
|
||||||
|
quit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused test and verify it fails for missing files**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `resources/chart_event.gd`, `resources/chart_track.gd`, `resources/beat_chart.gd`, and `scenes/chart/chart_runner.gd` do not exist yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Extend architecture test**
|
||||||
|
|
||||||
|
In `tests/test_rhythm_action_architecture.gd`, add a new function `_check_chart_layer()` and call it from `_run()` after `_check_autoloads()`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _check_chart_layer() -> void:
|
||||||
|
for path: String in [
|
||||||
|
"res://resources/chart_event.gd",
|
||||||
|
"res://resources/chart_track.gd",
|
||||||
|
"res://resources/beat_chart.gd",
|
||||||
|
"res://scenes/chart/chart_runner.gd",
|
||||||
|
]:
|
||||||
|
_expect(load(path) != null, "%s should load" % path)
|
||||||
|
var bus_script: Script = load("res://autoload/event_bus.gd")
|
||||||
|
_expect(bus_script != null, "EventBus should load for chart signal checks")
|
||||||
|
if bus_script != null:
|
||||||
|
var bus: Node = bus_script.new()
|
||||||
|
_expect(bus.has_signal("chart_event_upcoming"), "EventBus should expose chart_event_upcoming")
|
||||||
|
_expect(bus.has_signal("chart_event_triggered"), "EventBus should expose chart_event_triggered")
|
||||||
|
_expect(bus.has_signal("chart_reset"), "EventBus should expose chart_reset")
|
||||||
|
bus.free()
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: architecture test fails until the scripts and EventBus signals are added.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add Chart Resource Types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `resources/chart_event.gd`
|
||||||
|
- Create: `resources/chart_track.gd`
|
||||||
|
- Create: `resources/beat_chart.gd`
|
||||||
|
- Test: `tests/test_chart_layer.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `ChartEvent`**
|
||||||
|
|
||||||
|
Create `resources/chart_event.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ChartEvent
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var event_id: StringName = &""
|
||||||
|
@export var beat_index := 0
|
||||||
|
@export var subdivision := 0
|
||||||
|
@export var subdivisions_per_beat := 1
|
||||||
|
@export var event_type: StringName = &""
|
||||||
|
@export var target_id: StringName = &""
|
||||||
|
@export var payload: Dictionary = {}
|
||||||
|
@export var lead_beats := 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func beat_position() -> float:
|
||||||
|
var safe_subdivisions := maxi(1, subdivisions_per_beat)
|
||||||
|
return float(beat_index) + float(subdivision) / float(safe_subdivisions)
|
||||||
|
|
||||||
|
|
||||||
|
func time_seconds(beat_time: float) -> float:
|
||||||
|
return beat_position() * maxf(0.001, beat_time)
|
||||||
|
|
||||||
|
|
||||||
|
func key() -> StringName:
|
||||||
|
if not event_id.is_empty():
|
||||||
|
return event_id
|
||||||
|
return StringName("%s:%s:%d:%d" % [event_type, target_id, beat_index, subdivision])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `ChartTrack`**
|
||||||
|
|
||||||
|
Create `resources/chart_track.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ChartTrack
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var track_id: StringName = &""
|
||||||
|
@export var track_type: StringName = &""
|
||||||
|
@export var events: Array[ChartEvent] = []
|
||||||
|
|
||||||
|
|
||||||
|
func sorted_events() -> Array[ChartEvent]:
|
||||||
|
var result: Array[ChartEvent] = []
|
||||||
|
for event: ChartEvent in events:
|
||||||
|
if event != null:
|
||||||
|
result.append(event)
|
||||||
|
result.sort_custom(func(a: ChartEvent, b: ChartEvent) -> bool:
|
||||||
|
return a.beat_position() < b.beat_position()
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `BeatChart`**
|
||||||
|
|
||||||
|
Create `resources/beat_chart.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name BeatChart
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var chart_id: StringName = &""
|
||||||
|
@export var total_beats := 0
|
||||||
|
@export var tracks: Array[ChartTrack] = []
|
||||||
|
|
||||||
|
|
||||||
|
func all_events() -> Array[ChartEvent]:
|
||||||
|
var result: Array[ChartEvent] = []
|
||||||
|
for track: ChartTrack in tracks:
|
||||||
|
if track == null:
|
||||||
|
continue
|
||||||
|
for event: ChartEvent in track.sorted_events():
|
||||||
|
result.append(event)
|
||||||
|
result.sort_custom(func(a: ChartEvent, b: ChartEvent) -> bool:
|
||||||
|
if is_equal_approx(a.beat_position(), b.beat_position()):
|
||||||
|
return str(a.event_type) < str(b.event_type)
|
||||||
|
return a.beat_position() < b.beat_position()
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func is_empty() -> bool:
|
||||||
|
return all_events().is_empty()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the focused test and verify it still fails at `ChartRunner`**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `scenes/chart/chart_runner.gd` and EventBus chart signals are not implemented yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add Chart Signals to EventBus
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `autoload/event_bus.gd`
|
||||||
|
- Test: `tests/test_chart_layer.gd`
|
||||||
|
- Test: `tests/test_rhythm_action_architecture.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add chart signals**
|
||||||
|
|
||||||
|
In `autoload/event_bus.gd`, add these signals after the existing rhythm/judgement signals:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
signal chart_event_upcoming(event: Resource, time_to_event: float)
|
||||||
|
signal chart_event_triggered(event: Resource)
|
||||||
|
signal chart_reset(chart_id: StringName)
|
||||||
|
```
|
||||||
|
|
||||||
|
The top of the file should become:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal rhythm_action_requested(action_name: StringName)
|
||||||
|
signal beat_ticked(beat_index: int)
|
||||||
|
signal judgement_made(quality: StringName, offset_ms: float)
|
||||||
|
signal action_judged(action_name: StringName, rating: Dictionary)
|
||||||
|
signal chart_event_upcoming(event: Resource, time_to_event: float)
|
||||||
|
signal chart_event_triggered(event: Resource)
|
||||||
|
signal chart_reset(chart_id: StringName)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run architecture test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: still FAIL until `ChartRunner` exists, but EventBus chart signal assertions should pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Implement ChartRunner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/chart/chart_runner.gd`
|
||||||
|
- Test: `tests/test_chart_layer.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the chart directory and runner script**
|
||||||
|
|
||||||
|
Create `scenes/chart/chart_runner.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ChartRunner
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal chart_event_upcoming(event: ChartEvent, time_to_event: float)
|
||||||
|
signal chart_event_triggered(event: ChartEvent)
|
||||||
|
signal chart_reset(chart_id: StringName)
|
||||||
|
signal chart_finished(chart_id: StringName)
|
||||||
|
|
||||||
|
@export var chart: BeatChart
|
||||||
|
@export var rhythm_manager_path: NodePath
|
||||||
|
@export var beat_time_override := 0.0
|
||||||
|
@export var auto_run := true
|
||||||
|
|
||||||
|
var running := true
|
||||||
|
var _upcoming_keys: Dictionary = {}
|
||||||
|
var _triggered_keys: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
running = auto_run
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if not running or chart == null:
|
||||||
|
return
|
||||||
|
var rhythm := _rhythm_manager()
|
||||||
|
if rhythm == null or not rhythm.has_method("song_position"):
|
||||||
|
return
|
||||||
|
update_for_song_time(float(rhythm.call("song_position")))
|
||||||
|
|
||||||
|
|
||||||
|
func set_chart(next_chart: BeatChart) -> void:
|
||||||
|
chart = next_chart
|
||||||
|
reset()
|
||||||
|
|
||||||
|
|
||||||
|
func reset() -> void:
|
||||||
|
_upcoming_keys.clear()
|
||||||
|
_triggered_keys.clear()
|
||||||
|
var chart_id := &""
|
||||||
|
if chart != null:
|
||||||
|
chart_id = chart.chart_id
|
||||||
|
chart_reset.emit(chart_id)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_reset", chart_id)
|
||||||
|
|
||||||
|
|
||||||
|
func update_for_song_time(song_time: float) -> void:
|
||||||
|
if chart == null:
|
||||||
|
return
|
||||||
|
var beat_time := _beat_time()
|
||||||
|
for event: ChartEvent in chart.all_events():
|
||||||
|
var event_time := event.time_seconds(beat_time)
|
||||||
|
var time_to_event := event_time - song_time
|
||||||
|
var lead_time := maxf(0.0, event.lead_beats) * beat_time
|
||||||
|
var event_key := event.key()
|
||||||
|
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time:
|
||||||
|
_upcoming_keys[event_key] = true
|
||||||
|
_emit_upcoming(event, time_to_event)
|
||||||
|
if not _triggered_keys.has(event_key) and song_time >= event_time:
|
||||||
|
_triggered_keys[event_key] = true
|
||||||
|
_emit_triggered(event)
|
||||||
|
|
||||||
|
|
||||||
|
func pause() -> void:
|
||||||
|
running = false
|
||||||
|
|
||||||
|
|
||||||
|
func resume() -> void:
|
||||||
|
running = true
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_upcoming(event: ChartEvent, time_to_event: float) -> void:
|
||||||
|
chart_event_upcoming.emit(event, time_to_event)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_event_upcoming", event, time_to_event)
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_triggered(event: ChartEvent) -> void:
|
||||||
|
chart_event_triggered.emit(event)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_event_triggered", event)
|
||||||
|
|
||||||
|
|
||||||
|
func _beat_time() -> float:
|
||||||
|
if beat_time_override > 0.0:
|
||||||
|
return beat_time_override
|
||||||
|
var rhythm := _rhythm_manager()
|
||||||
|
if rhythm != null:
|
||||||
|
return float(rhythm.get("beat_time"))
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
func _rhythm_manager() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
if not rhythm_manager_path.is_empty():
|
||||||
|
return get_node_or_null(rhythm_manager_path)
|
||||||
|
return get_tree().root.get_node_or_null("RhythmManager")
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("EventBus")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused chart test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run architecture test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS after `_check_chart_layer()` assertions are satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add Runtime ChartRunner Node
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scenes/main/main.tscn`
|
||||||
|
- Optional create after scripts compile: `resources/charts/test_song_chart.tres`
|
||||||
|
- Test: `tests/test_rhythm_scene.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `ChartRunner` to `Main` scene**
|
||||||
|
|
||||||
|
Modify `scenes/main/main.tscn`.
|
||||||
|
|
||||||
|
Add an external resource:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[ext_resource type="Script" path="res://scenes/chart/chart_runner.gd" id="6_chart_runner"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add this node between `Stage` and `UI`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[node name="ChartRunner" type="Node" parent="."]
|
||||||
|
script = ExtResource("6_chart_runner")
|
||||||
|
```
|
||||||
|
|
||||||
|
The intended main scene tree becomes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Main
|
||||||
|
├─ Stage
|
||||||
|
├─ ChartRunner
|
||||||
|
└─ UI
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scene architecture test**
|
||||||
|
|
||||||
|
In `tests/test_rhythm_scene.gd`, add an assertion that main scene has `ChartRunner`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var main_scene: PackedScene = load("res://scenes/main/main.tscn")
|
||||||
|
var main := main_scene.instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
await process_frame
|
||||||
|
if main.get_node_or_null("ChartRunner") == null:
|
||||||
|
failures.append("Main should include ChartRunner for scene-owned chart playback")
|
||||||
|
main.free()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run scene test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_scene.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add a sample chart resource through the Godot editor**
|
||||||
|
|
||||||
|
After `ChartEvent`, `ChartTrack`, and `BeatChart` scripts compile, create `resources/charts/test_song_chart.tres` in the editor with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BeatChart.chart_id = test_song_chart
|
||||||
|
BeatChart.total_beats = 32
|
||||||
|
|
||||||
|
Track accent:
|
||||||
|
beat 4 show_accent_marker
|
||||||
|
beat 8 show_accent_marker
|
||||||
|
beat 12 show_accent_marker
|
||||||
|
beat 16 show_accent_marker
|
||||||
|
|
||||||
|
Track enemy:
|
||||||
|
beat 6 enemy_prepare_attack target_id=test_enemy lead_beats=1.0
|
||||||
|
beat 7 enemy_attack_active target_id=test_enemy lead_beats=1.0
|
||||||
|
beat 8 enemy_recovery target_id=test_enemy lead_beats=0.5
|
||||||
|
|
||||||
|
Track camera:
|
||||||
|
beat 12 camera_pulse target_id=main_camera lead_beats=0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Assign this resource to `Main/ChartRunner.chart`.
|
||||||
|
|
||||||
|
The plan keeps runtime playable without this sample chart because tests construct chart resources in memory. The sample chart is for visual/manual verification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Let RhythmTrack Consume Chart Events
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scenes/ui/rhythm_track.tscn`
|
||||||
|
- Modify: `scenes/ui/rhythm_track.gd`
|
||||||
|
- Test: `tests/test_rhythm_ui.gd`
|
||||||
|
- Test: `tests/test_ui_animation_regression.gd`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add marker container to scene**
|
||||||
|
|
||||||
|
In `scenes/ui/rhythm_track.tscn`, add:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[node name="ChartMarkerContainer" type="Control" parent="."]
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 0.0
|
||||||
|
offset_top = 0.0
|
||||||
|
offset_right = 1040.0
|
||||||
|
offset_bottom = 128.0
|
||||||
|
mouse_filter = 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep it behind `JudgementLabel` in the file order so text remains readable.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend `RhythmTrack` script**
|
||||||
|
|
||||||
|
In `scenes/ui/rhythm_track.gd`, add:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
@onready var chart_marker_container: Control = $ChartMarkerContainer
|
||||||
|
|
||||||
|
var chart_markers: Array[Control] = []
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect chart signals in `_ready()`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
bus.connect("chart_event_upcoming", _on_chart_event_upcoming)
|
||||||
|
bus.connect("chart_event_triggered", _on_chart_event_triggered)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these methods:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _on_chart_event_upcoming(event: Resource, time_to_event: float) -> void:
|
||||||
|
var marker := Label.new()
|
||||||
|
marker.text = _chart_marker_text(event)
|
||||||
|
marker.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
marker.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
marker.add_theme_font_size_override("font_size", 14)
|
||||||
|
marker.add_theme_color_override("font_color", _chart_marker_color(event))
|
||||||
|
marker.custom_minimum_size = Vector2(54, 24)
|
||||||
|
marker.position = _chart_marker_position(time_to_event)
|
||||||
|
chart_marker_container.add_child(marker)
|
||||||
|
chart_markers.append(marker)
|
||||||
|
var tween := create_tween()
|
||||||
|
tween.tween_property(marker, "modulate:a", 0.25, maxf(0.1, time_to_event))
|
||||||
|
tween.tween_callback(marker.queue_free)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_chart_event_triggered(event: Resource) -> void:
|
||||||
|
if StringName(str(event.get("event_type"))) == &"camera_pulse":
|
||||||
|
center_flash.modulate = Color(1.0, 0.84, 0.26, 1.0)
|
||||||
|
else:
|
||||||
|
center_flash.modulate = _chart_marker_color(event)
|
||||||
|
beat_flash = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _chart_marker_text(event: Resource) -> String:
|
||||||
|
match StringName(str(event.get("event_type"))):
|
||||||
|
&"show_accent_marker":
|
||||||
|
return "ACC"
|
||||||
|
&"enemy_prepare_attack":
|
||||||
|
return "WARN"
|
||||||
|
&"enemy_attack_active":
|
||||||
|
return "ATK"
|
||||||
|
&"enemy_recovery":
|
||||||
|
return "REC"
|
||||||
|
&"camera_pulse":
|
||||||
|
return "CAM"
|
||||||
|
return str(event.get("event_type")).to_upper()
|
||||||
|
|
||||||
|
|
||||||
|
func _chart_marker_color(event: Resource) -> Color:
|
||||||
|
match StringName(str(event.get("event_type"))):
|
||||||
|
&"show_accent_marker":
|
||||||
|
return Color("ffd84a")
|
||||||
|
&"enemy_prepare_attack":
|
||||||
|
return Color("ff7a33")
|
||||||
|
&"enemy_attack_active":
|
||||||
|
return Color("ff3355")
|
||||||
|
&"enemy_recovery":
|
||||||
|
return Color("8aa0ff")
|
||||||
|
&"camera_pulse":
|
||||||
|
return Color("ffffff")
|
||||||
|
return Color("00f2ff")
|
||||||
|
|
||||||
|
|
||||||
|
func _chart_marker_position(time_to_event: float) -> Vector2:
|
||||||
|
var seconds_per_beat := 60.0 / maxf(1.0, bpm)
|
||||||
|
var beat_distance := clampf(time_to_event / seconds_per_beat, 0.0, 4.0)
|
||||||
|
var x := track_center.x + beat_distance * 92.0
|
||||||
|
return Vector2(x - 27.0, track_center.y + 34.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add UI tests**
|
||||||
|
|
||||||
|
In `tests/test_rhythm_ui.gd`, assert the marker container exists:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var track := ui.get_node_or_null("RhythmTrack")
|
||||||
|
if track == null or track.get_node_or_null("ChartMarkerContainer") == null:
|
||||||
|
failures.append("RhythmTrack should include ChartMarkerContainer")
|
||||||
|
```
|
||||||
|
|
||||||
|
In `tests/test_ui_animation_regression.gd`, emit an upcoming event and assert a marker appears:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var event_script: Script = load("res://resources/chart_event.gd")
|
||||||
|
var event: Resource = event_script.new()
|
||||||
|
event.set("event_type", &"enemy_attack_active")
|
||||||
|
bus.emit_signal("chart_event_upcoming", event, 0.5)
|
||||||
|
await process_frame
|
||||||
|
var marker_container := ui.get_node("RhythmTrack/ChartMarkerContainer")
|
||||||
|
_expect_bool(marker_container.get_child_count() > 0, true, "Chart upcoming event should create a rhythm marker")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run UI tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_ui_animation_regression.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full Regression Run
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No new files.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run chart and architecture tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_chart_layer.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS chart layer
|
||||||
|
PASS rhythm action architecture
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run player input/action tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_input_component_intents.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_action_controller_flow.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS input component intents
|
||||||
|
PASS combo window
|
||||||
|
PASS action controller flow
|
||||||
|
PASS player combo input
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run UI tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_ui_layout.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_ui_animation_regression.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS rhythm ui
|
||||||
|
PASS rhythm ui layout
|
||||||
|
PASS ui animation regression
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Follow-Up After This Plan
|
||||||
|
|
||||||
|
After the thin Chart Layer lands, the next feature should be a chart-driven dummy enemy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chart_event_upcoming(enemy_prepare_attack)
|
||||||
|
-> EnemyWarningPresenter shows overhead warning
|
||||||
|
|
||||||
|
chart_event_triggered(enemy_attack_active)
|
||||||
|
-> EnemyActionController opens enemy hitbox
|
||||||
|
|
||||||
|
chart_event_triggered(enemy_recovery)
|
||||||
|
-> EnemyActionController closes enemy hitbox
|
||||||
|
```
|
||||||
|
|
||||||
|
That should be a separate plan because it needs enemy scene structure, hitbox/hurtbox decisions, target IDs, and warning UI. Keeping it separate prevents the Chart Layer from becoming a hidden enemy-system rewrite.
|
||||||
|
|
||||||
|
## Important Non-Goals
|
||||||
|
|
||||||
|
- Do not move player input judgement into Chart Layer.
|
||||||
|
- Do not make charts dictate required player keys.
|
||||||
|
- Do not make `ChartRunner` an autoload.
|
||||||
|
- Do not change `ComboWindow` empty-beat behavior.
|
||||||
|
- Do not add full boss AI in this pass.
|
||||||
|
- Do not use chart events to directly damage the player.
|
||||||
|
|
||||||
|
## Known Adjacent Risk
|
||||||
|
|
||||||
|
`RhythmManager.judge(input_timestamp_ms)` currently accepts an input timestamp in milliseconds and passes it to `get_rating_for_time(input_timestamp_ms / 1000.0)`. Chart Layer should not depend on this method; it should use `RhythmManager.song_position()` directly. A separate input-timing plan should verify whether `InputIntent.timestamp_ms` is being converted to song-relative time correctly.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: The plan covers chart data, runner behavior, EventBus mirroring, main scene integration, UI marker consumption, and regression tests.
|
||||||
|
- Placeholder scan: The plan has no placeholder sections. Every task names exact files and expected commands.
|
||||||
|
- Type consistency: Event resources are `ChartEvent`, tracks are `ChartTrack`, charts are `BeatChart`, and runner signals use `ChartEvent` locally plus `Resource` on `EventBus`.
|
||||||
|
- Scope check: Enemy AI is intentionally excluded and captured as the next separate feature.
|
||||||
|
- Current-project fit: The plan preserves the existing `RhythmManager`, `ActionController`, `ComboWindow`, `ActionResolver`, and player UI patterns.
|
||||||
116
docs/superpowers/plans/2026-07-02-godot-architecture-refactor.md
Normal file
116
docs/superpowers/plans/2026-07-02-godot-architecture-refactor.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Godot Architecture Refactor Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Refactor `Fighting_Rthythm_game` to match the provided architecture: EventBus communication, thin Main, componentized Player, SkillData resources, UI subscenes, Stage/ActorsContainer ownership, and named 2D layers.
|
||||||
|
|
||||||
|
**Architecture:** Keep the game runnable at each increment. Cross-system communication goes through `autoload/event_bus.gd`; parent-to-child orchestration uses typed references; child-to-parent requests use signals. Player becomes a coordinator over child components, while skill definitions move to `.tres` resources loaded by `InputResolver`.
|
||||||
|
|
||||||
|
**Tech Stack:** Godot 4.6 GDScript, `.tscn` scenes, `.tres` resources, headless SceneTree tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Architecture Guard Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_architecture_refactor.gd`
|
||||||
|
- Modify: `project.godot`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Add tests that assert EventBus is autoloaded, Main no longer hand-wires UI with `has_method`/`has_signal`, Player has child components, Player does not use raw `KEY_*`, skills load as `SkillData`, Stage owns `ActorsContainer`, UI exists as a separate scene, and project 2D layers are named.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_architecture_refactor.gd`
|
||||||
|
|
||||||
|
Expected: FAIL because the current project lacks EventBus, component nodes, resource skills, UI subscenes, and Stage boundaries.
|
||||||
|
|
||||||
|
### Task 2: EventBus And Rhythm Decoupling
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `autoload/event_bus.gd`
|
||||||
|
- Modify: `project.godot`
|
||||||
|
- Modify: `scenes/rhythm/rhythm_conductor.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
|
||||||
|
- [ ] Add EventBus signals for rhythm requests, beats, judgements, skills, health, energy, charge, combo, damage, and projectile requests.
|
||||||
|
- [ ] Register EventBus as a game autoload.
|
||||||
|
- [ ] Make RhythmConductor listen for rhythm action requests and emit EventBus judgement/beat signals.
|
||||||
|
- [ ] Remove Player's `get_first_node_in_group("rhythm_conductor")` fallback and treat missing judgement as a miss.
|
||||||
|
|
||||||
|
### Task 3: Player Components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/components/input_component.gd`
|
||||||
|
- Create: `scenes/components/combo_tracker.gd`
|
||||||
|
- Create: `scenes/components/energy_component.gd`
|
||||||
|
- Create: `scenes/components/health_component.gd`
|
||||||
|
- Create: `scenes/components/damage_emitter.gd`
|
||||||
|
- Create: `scenes/components/damage_receiver.gd`
|
||||||
|
- Create: `scenes/components/state_machine.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
- Modify: `scenes/characters/player.tscn`
|
||||||
|
- Modify: `project.godot`
|
||||||
|
|
||||||
|
- [ ] Move raw input handling into InputComponent using only InputMap actions.
|
||||||
|
- [ ] Move combo slot storage and clear timing into ComboTracker.
|
||||||
|
- [ ] Move energy/health state into components that emit EventBus updates.
|
||||||
|
- [ ] Add DamageEmitter and DamageReceiver Area2D components with collision-layer-based targeting.
|
||||||
|
- [ ] Keep Player as a typed coordinator over components.
|
||||||
|
|
||||||
|
### Task 4: SkillData Resources
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `resources/skill_data.gd`
|
||||||
|
- Create: `resources/skills/*.tres`
|
||||||
|
- Modify: `scenes/combat/input_resolver.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
|
||||||
|
- [ ] Define `SkillData extends Resource`.
|
||||||
|
- [ ] Convert each hard-coded skill pattern into a `.tres` resource.
|
||||||
|
- [ ] Make InputResolver load resources and return `SkillData`.
|
||||||
|
- [ ] Move animation, energy cost/reward, projectile flags, clear-window behavior, and displacement into resources.
|
||||||
|
|
||||||
|
### Task 5: UI Subscenes And Thin Main
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/ui/main_ui.tscn`
|
||||||
|
- Create: `scenes/ui/main_ui.gd`
|
||||||
|
- Create: `scenes/ui/rhythm_track.tscn`
|
||||||
|
- Create: `scenes/ui/rhythm_track.gd`
|
||||||
|
- Create: `scenes/ui/combo_window_hud.tscn`
|
||||||
|
- Create: `scenes/ui/combo_window_hud.gd`
|
||||||
|
- Create: `scenes/ui/energy_bar.tscn`
|
||||||
|
- Create: `scenes/ui/energy_bar.gd`
|
||||||
|
- Modify: `scenes/main/main.gd`
|
||||||
|
- Modify: `scenes/main/main.tscn`
|
||||||
|
|
||||||
|
- [ ] Move rhythm track animation into `RhythmTrack`.
|
||||||
|
- [ ] Move combo slot rendering into `ComboWindowHud`.
|
||||||
|
- [ ] Move energy/health/charge display into UI nodes that subscribe to EventBus.
|
||||||
|
- [ ] Reduce Main to typed child references and scene setup only.
|
||||||
|
|
||||||
|
### Task 6: Stage And ActorsContainer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/stage/stage.tscn`
|
||||||
|
- Create: `scenes/stage/stage.gd`
|
||||||
|
- Create: `scenes/stage/actors_container.gd`
|
||||||
|
- Create: `scenes/combat/player_projectile.tscn`
|
||||||
|
- Modify: `scenes/combat/player_projectile.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
- Modify: `scenes/main/main.tscn`
|
||||||
|
|
||||||
|
- [ ] Put ground and Player under Stage/ActorsContainer.
|
||||||
|
- [ ] Make Player emit projectile requests instead of adding children to the scene tree.
|
||||||
|
- [ ] Make ActorsContainer instantiate projectiles, own them, and group them.
|
||||||
|
|
||||||
|
### Task 7: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: tests as needed to target public component interfaces instead of Player internals.
|
||||||
|
|
||||||
|
- [ ] Run every `tests/*.gd` with Godot headless.
|
||||||
|
- [ ] Confirm architecture guard tests cover every explicit objective item.
|
||||||
|
- [ ] Inspect key source files to ensure raw key matching, group conductor lookup, Player projectile spawning, and Main `has_method`/`has_signal` probing are gone.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Rhythm Action Architecture Refactor Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Refactor the project toward the architecture in `docs/架构方案.md`: global beat clock, four-slot combo window, ActionData resources, ActionResolver priority rules, Player execution components, and CombatManager formulas.
|
||||||
|
|
||||||
|
**Architecture:** Keep `EventBus` for decoupled broadcast, but add `RhythmManager` and `CombatManager` as first-class autoload services. Move domain logic out of `Player` into beat-aware components and data-driven resource files while preserving current gameplay tests.
|
||||||
|
|
||||||
|
**Tech Stack:** Godot 4.6 GDScript, `.tres` Resources, headless SceneTree tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Architecture Target Regression
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_rhythm_action_architecture.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create one test that asserts the six requested architecture artifacts exist and expose their intended behavior:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
_check_autoloads()
|
||||||
|
_check_action_data()
|
||||||
|
_check_combo_window()
|
||||||
|
_check_action_resolver()
|
||||||
|
_check_player_components()
|
||||||
|
_check_combat_manager()
|
||||||
|
_finish()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `RhythmManager`, `CombatManager`, `ActionData`, `ActionResolver`, `ComboWindow`, `MotionExecutor`, and `BurstComponent` do not exist yet.
|
||||||
|
|
||||||
|
### Task 2: RhythmManager Autoload
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `autoload/rhythm_manager.gd`
|
||||||
|
- Modify: `project.godot`
|
||||||
|
- Modify: `scenes/rhythm/rhythm_conductor.gd`
|
||||||
|
- Modify: `tests/test_rhythm_conductor.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Implement `RhythmManager`**
|
||||||
|
|
||||||
|
Create an Autoload node with `beat_ticked`, `judgement_made`, and `action_judged` signals. It owns BPM, beat length, beat index, judgement scale, beat offset, fallback clock timing, and `judge_action(action_name)`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Register autoload**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[autoload]
|
||||||
|
RhythmManager="*res://autoload/rhythm_manager.gd"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **Step 3: Preserve `RhythmConductor` compatibility**
|
||||||
|
|
||||||
|
Keep `scenes/rhythm/rhythm_conductor.gd` loadable for old tests by delegating equivalent judging math to the same model or leaving it as a scene adapter.
|
||||||
|
|
||||||
|
- [x] **Step 4: Run rhythm tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_conductor.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_rhythm_action_architecture.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: rhythm conductor still passes; architecture test advances past autoload checks.
|
||||||
|
|
||||||
|
### Task 3: Four-Slot ComboWindow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/components/combo_window.gd`
|
||||||
|
- Modify: `scenes/characters/player.tscn`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
- Modify: `tests/test_combo_window.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Implement `ComboWindow`**
|
||||||
|
|
||||||
|
`ComboWindow` extends `Node`, stores four slots, records `StringName` inputs, keeps `&"Ø"` only as an explicit Miss placeholder, clears on miss/full/action clear, and emits `combo_updated`/`combo_cleared`. It must not auto-append `&"Ø"` just because a beat passed without input.
|
||||||
|
|
||||||
|
- [x] **Step 2: Replace Player node**
|
||||||
|
|
||||||
|
Rename or replace `ComboTracker` with `ComboWindow` in `player.tscn`. Keep compatibility methods `get_slots()`, `get_pattern()`, `get_contiguous_pattern()`, `queue_clear()`, `flush_pending_clear()` so existing tests can continue running while behavior becomes component-based.
|
||||||
|
|
||||||
|
- [x] **Step 3: Run combo tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_combo_window.gd
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s res://tests/test_player_combo_input.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: existing combo behavior remains green, and the new architecture test confirms ComboWindow does not auto-append `&"Ø"` just because a beat passed without input.
|
||||||
|
|
||||||
|
### Task 4: ActionData and ActionResolver
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `resources/action_data.gd`
|
||||||
|
- Create: `resources/actions/*.tres`
|
||||||
|
- Create: `scenes/combat/action_resolver.gd`
|
||||||
|
- Modify: `resources/skill_data.gd`
|
||||||
|
- Modify: `scenes/combat/input_resolver.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
- Modify: `tests/test_combo_window.gd`
|
||||||
|
- Modify: `tests/test_architecture_refactor.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add full `ActionData`**
|
||||||
|
|
||||||
|
Create `ActionData extends Resource` with fields from `docs/架构方案.md`: `id`, `display_name`, `input_pattern`, `required_state`, `base_cost`, `damage_mult`, `move_mult_x`, `move_mult_y`, `action_beats`, `hit_type`, `range`, `target_type`, `armor_level`, `clear_window`, `can_chain`, `special`, plus compatibility fields `animation`, `energy_cost`, `energy_reward`, `spawns_projectile`, `projectile_scene`, and `displacement`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Convert resources**
|
||||||
|
|
||||||
|
Create `resources/actions` equivalents for the current `resources/skills` files. After all tests and scripts use `ActionData`, remove the legacy `resources/skills` data.
|
||||||
|
|
||||||
|
- [x] **Step 3: Implement `ActionResolver`**
|
||||||
|
|
||||||
|
Load `resources/actions/*.tres`, expose `resolve(window, state_machine := null, context := {})`, `resolve_pattern(pattern, state_machine := null, context := {})`, `reload()`, and `clear_cache()`. Include an ordered rule pass for Space contexts before falling back to pattern matching.
|
||||||
|
|
||||||
|
- [x] **Step 4: Remove `InputResolver` after migration**
|
||||||
|
|
||||||
|
Move tests and runtime code to `ActionResolver` directly, then remove the legacy `InputResolver` adapter.
|
||||||
|
|
||||||
|
### Task 5: MotionExecutor and BurstComponent
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scenes/components/motion_executor.gd`
|
||||||
|
- Create: `scenes/components/burst_component.gd`
|
||||||
|
- Modify: `scenes/characters/player.tscn`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Add `MotionExecutor`**
|
||||||
|
|
||||||
|
Expose `execute(action, direction, beat_time)` and `tick(delta)` to own action duration, lunge timing, and velocity output. Player calls it instead of hand-writing lunge windows.
|
||||||
|
|
||||||
|
- [x] **Step 2: Add `BurstComponent`**
|
||||||
|
|
||||||
|
Expose ready/active/cooldown state, beat-based duration, cost multiplier, damage multiplier, and judgement scale. It listens to `RhythmManager.beat_ticked` for duration and cooldown.
|
||||||
|
|
||||||
|
- [x] **Step 3: Move Player logic**
|
||||||
|
|
||||||
|
Replace direct charge/burst timing in `Player` with `BurstComponent`, and replace direct action displacement with `MotionExecutor`.
|
||||||
|
|
||||||
|
### Task 6: CombatManager
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `autoload/combat_manager.gd`
|
||||||
|
- Modify: `project.godot`
|
||||||
|
- Modify: `scenes/components/damage_emitter.gd`
|
||||||
|
- Modify: `scenes/characters/player.gd`
|
||||||
|
|
||||||
|
- [x] **Step 1: Implement formulas**
|
||||||
|
|
||||||
|
`CombatManager` exposes `resolve_damage(base_attack, action, judgement, buffs := null, burst := null)`, `resolve_cost(action, burst := null)`, and `resolve_move(action, judgement, burst := null)`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Wire damage and cost**
|
||||||
|
|
||||||
|
Player uses `CombatManager.resolve_cost` before action execution. `DamageEmitter` can accept an action context and asks `CombatManager.resolve_damage` rather than using scattered multipliers.
|
||||||
|
|
||||||
|
### Task 7: Verification and Gap Audit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: tests as needed to cover exact behavior.
|
||||||
|
|
||||||
|
- [x] **Step 1: Run full suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for test in tests/*.gd; do
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot --headless --path /Users/wxm/code/project/Fighting_Rthythm_game -s "res://$test" || exit 1
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests exit `0`.
|
||||||
|
|
||||||
|
- [x] **Step 2: Search for architecture residue**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rg -n "Timer|charge_duration|attack_lunge|InputResolver|SkillData|ComboTracker|PlayerProjectile\\.new\\(|get_first_node_in_group|has_method|\\.call\\(" autoload resources scenes tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: any remaining hits are compatibility adapters, tests, or UI-only visual timing, not core gameplay architecture.
|
||||||
182
docs/架构方案.md
Normal file
182
docs/架构方案.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
读完这份策划书,先说结论:前两轮的架构骨架(Autoload 总线 + 组件化 + Resource 数据驱动)完全适用,但策划书里有三个事实会**反过来修正**我之前的一些具体建议,先把它们摆清楚,因为整个架构都要围绕它们展开。
|
||||||
|
|
||||||
|
**第一,拍(Beat)是全游戏唯一的时间单位。** 动作持续用 ActionBeats,Buff 持续 8 拍,爆气 16 拍、冷却 4 拍,蓄力按拍分级,敌人每 N 拍行动,连段窗口是 4 拍。这意味着 RhythmManager 不只是"判定服务",它是**全局主时钟**,所有系统的计时都应该订阅它的 `beat_ticked`,而不是各自开 Timer 数秒——这样换 BPM 时整个游戏自动变速,这正是节奏游戏该有的性质。
|
||||||
|
|
||||||
|
**第二,我要收回上一轮"输入缓冲"的建议。** InputBuffer 是格斗游戏的手感工具,但在你这个设计里它是有害的:判定的本质是测量按键时刻与拍点的偏差,缓冲会把"提前按"偷偷延后消费,等于篡改了玩家的真实节奏。而且策划书的 Miss 机制天然解决了连打问题——乱按的人会大量 Miss,Miss 清空窗口、动作失败,惩罚是玩法自带的,不需要程序层再去限速。保留的只有 echo 过滤、去重和时间戳这些"让输入干净"的部分。
|
||||||
|
|
||||||
|
**第三,策划书 9.1 节其实已经替你把数据模型设计好了。** 那个统一技能字段表(inputPattern、requiredState、baseCost、各倍率、hitType、clearWindow、canChain……)就是 `ActionData extends Resource` 的字段清单,而且基础动作和技能同构——A 一段斩就是一个 cost=0、pattern=[A] 的 ActionData。全游戏 30 多个动作全部变成 `.tres` 文件,代码里没有任何一张硬编码技能表。
|
||||||
|
|
||||||
|
## 一、全局层:RhythmManager 作为节拍中枢RhythmManager(Autoload)的接口对应策划书 2.1 的全局单位表:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# autoload/rhythm_manager.gd
|
||||||
|
signal beat_ticked(beat_index: int)
|
||||||
|
|
||||||
|
var bpm: float
|
||||||
|
var beat_time: float # 60.0 / bpm
|
||||||
|
var beat_index: int
|
||||||
|
var judgement_scale := 1.0 # 爆气时 > 1,放宽判定窗口
|
||||||
|
|
||||||
|
func song_position() -> float:
|
||||||
|
return music.get_playback_position() \
|
||||||
|
+ AudioServer.get_time_since_last_mix() \
|
||||||
|
- AudioServer.get_output_latency()
|
||||||
|
|
||||||
|
func judge(input_timestamp_ms: float) -> Judgement:
|
||||||
|
# 换算到歌曲时间,求与最近拍点的 BeatOffset
|
||||||
|
# 与 RhythmConfig 中的阈值比较 → perfect / good / bad / miss
|
||||||
|
# 阈值乘以 judgement_scale 实现爆气放宽
|
||||||
|
```
|
||||||
|
|
||||||
|
判定阈值放进 `rhythm_config.tres` 资源(Perfect/Good/Bad 各自的偏差上限)——**策划书目前没有给这组数字**,这是要找策划补的第一个洞。爆气的"判定窗口适度放宽"通过 `judgement_scale` 一个字段实现,BurstComponent 进入爆气时改它,退出时还原。
|
||||||
|
|
||||||
|
CombatManager(Autoload)则是四条乘区公式的唯一归属地。策划书 2.3 的公式结构完全一致(基础值 × 动作乘区 × 节奏乘区 × Buff 乘区 × 爆气乘区),所以做一个统一的结算管线:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# autoload/combat_manager.gd
|
||||||
|
func resolve_damage(atk: float, action: ActionData, j: Judgement,
|
||||||
|
buffs: BuffContainer, burst: BurstComponent) -> float:
|
||||||
|
return atk * action.damage_mult * j.damage_mult \
|
||||||
|
* buffs.damage_mult(action) * burst.damage_mult()
|
||||||
|
|
||||||
|
# resolve_move_x / resolve_move_y / resolve_cost 同构,只换字段
|
||||||
|
```
|
||||||
|
|
||||||
|
各系统只负责"贡献自己的乘数"(比如强袭乐句 Buff 只对同方向技能生效,所以 `buffs.damage_mult(action)` 要传入动作),永远不要在 Player 或技能代码里手写 `damage * 1.25 * 1.2` 这种散落的乘法——否则第 4 条公式改一次你要全项目搜一遍。
|
||||||
|
|
||||||
|
## 二、数据层:ActionData 照抄策划书 9.1
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# resources/action_data.gd
|
||||||
|
class_name ActionData extends Resource
|
||||||
|
|
||||||
|
@export var id: StringName
|
||||||
|
@export var display_name: String
|
||||||
|
@export var input_pattern: Array[StringName] # [&"A", &"A", &"space"]
|
||||||
|
@export_enum("ground", "air", "guarding", "any") var required_state: String
|
||||||
|
@export var base_cost := 0 # 基础动作恒为 0
|
||||||
|
@export var damage_mult := 1.0
|
||||||
|
@export var move_mult_x := 0.0
|
||||||
|
@export var move_mult_y := 0.0
|
||||||
|
@export var action_beats := 1.0 # W 为 2.0
|
||||||
|
@export_enum("melee", "projectile", "circle", "counter") var hit_type: String
|
||||||
|
@export var range := 0.0
|
||||||
|
@export_enum("single", "area") var target_type: String
|
||||||
|
@export var armor_level := 0
|
||||||
|
@export var clear_window := true # 音刃前两段为 false
|
||||||
|
@export var can_chain := false # 音刃族为 true
|
||||||
|
@export var special: StringName # 破霸体、浮空等特效钩子
|
||||||
|
```
|
||||||
|
|
||||||
|
A/D 三段、W、招架、下劈、W 派生、四个方向技能、三段音刃、反击音刃、三级蓄力——全部是这个类的 `.tres` 实例,放在 `res://resources/actions/` 下。第一版验收标准 17.4 里"A Space、AA Space、AAA Space 功能不同"这种需求,变成纯粹的资源文件差异。
|
||||||
|
|
||||||
|
## 三、玩家实体:组件划分随策划书调整
|
||||||
|
|
||||||
|
玩家场景树在上一轮基础上,按这份策划书的系统重新切分:
|
||||||
|
|
||||||
|
```
|
||||||
|
Player (CharacterBody2D)
|
||||||
|
├── Sprite / 骨骼动画
|
||||||
|
├── StateMachine # 8 个状态,照抄 3.2 节
|
||||||
|
│ └── ground / air / guarding / charging /
|
||||||
|
│ bladeChain / burstCharge / bursting / hitstun
|
||||||
|
├── InputComponent # 按下+松开事件、时间戳、长按检测
|
||||||
|
├── ComboWindow # 四槽连段窗口,只记录显式输入/Miss,不自动补空拍
|
||||||
|
├── ActionResolver # Space 优先级链 + 动作表匹配(纯逻辑)
|
||||||
|
├── MotionExecutor # 把位移乘区结果变成 ActionBeats 内的实际位移
|
||||||
|
├── EnergyComponent # 回能规则 + 空挥计数器
|
||||||
|
├── BuffContainer # 四个 Buff 的触发、拍计时、乘区供给
|
||||||
|
├── BurstComponent # 爆气条件、四态、效果开关
|
||||||
|
├── DamageEmitter (Area2D)
|
||||||
|
└── DamageReceiver (Area2D)
|
||||||
|
```
|
||||||
|
|
||||||
|
几个组件值得单独说透。
|
||||||
|
|
||||||
|
**ComboWindow:它是连段窗口的领域对象,不是输入缓冲,也不负责空拍补位。** 这里按当前设计修正:ComboWindow 不订阅 `beat_ticked` 来自动补 Ø,某一拍没有输入就什么都不记录。它只记录两类内容:通过节奏判定的显式输入(A/D/W/S/Space),以及 Miss 时由裁决层显式写入的 Ø 占位。Miss 只是正常槽位输入,不因自身触发清空;满 4 槽清空;受击清空(监听 DamageReceiver);`clear_window == true` 的动作释放后清空;bladeChain 期间按专门规则决定是否保留窗口。识别连段时对外暴露过滤掉 Ø 的有效序列,但正常玩法里 Ø 只作为 Miss 反馈,不会因为空拍自动出现:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# components/combo_window.gd
|
||||||
|
class_name ComboWindow extends Node
|
||||||
|
signal cleared(reason: StringName)
|
||||||
|
var slots: Array[StringName] = []
|
||||||
|
|
||||||
|
func record(action: StringName) -> void: # 判定非 Miss 后调用
|
||||||
|
slots.append(action)
|
||||||
|
if slots.size() >= 4:
|
||||||
|
clear(&"window_full")
|
||||||
|
|
||||||
|
func record_miss() -> void: # 仅 Miss 裁决显式调用
|
||||||
|
slots.append(&"Ø")
|
||||||
|
clear(&"miss")
|
||||||
|
|
||||||
|
func pattern() -> Array[StringName]: # 供 ActionResolver 匹配
|
||||||
|
return slots.filter(func(s): return s != &"Ø")
|
||||||
|
```
|
||||||
|
|
||||||
|
这样窗口的语义会更干净:连段只由玩家真实输入构成;Miss 是显式失败反馈并清空窗口;空拍不会污染连段,也不会制造需要额外解释的隐藏槽位。
|
||||||
|
|
||||||
|
**ActionResolver:Space 的六步优先级链是全项目最容易腐坏的逻辑,必须独立成模块。** 而且注意策划书 10.3 说反击音刃"优先级高于普通 S Space 音刃",但第 6 节的解析顺序里没写它——合并后的完整链条是七步:实现上把这七步写成一个有序规则数组,每条规则是"条件函数 + 产出动作",Resolver 逐条尝试、命中即返回。这样以后加 Q/E、装备技能替换(策划书 18 节的暂缓内容)只是往数组里插规则,不用动主干。
|
||||||
|
|
||||||
|
**InputComponent:长按检测暴露了一个策划书没定义的关键时序问题。** S Space 短按是音刃、长按是爆气——但按下的瞬间程序不可能知道玩家会不会长按,而策划书 4.1 又要求"按键时立刻判定"。这两条规则在 Space 上是冲突的,必须由程序定义结算时机。我建议的方案是**按下即判定、延迟结算**:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# InputComponent 对 Space 的处理
|
||||||
|
# 按下: 立即调用 RhythmManager.judge() 并暂存结果(pending)
|
||||||
|
# 若在 hold_threshold(建议 0.3~0.5 拍,需策划确认)内松开:
|
||||||
|
# → 按短按结算,使用按下那一刻的判定结果(节奏不失真)
|
||||||
|
# 若超过阈值仍按住:
|
||||||
|
# → 丢弃 pending,升级为 charging / burstCharge 状态
|
||||||
|
# → 松开时重新判定(策划书 11.1 / 12.4 本来就要求松开判定)
|
||||||
|
```
|
||||||
|
|
||||||
|
代价是短按的动作会比按键晚约三分之一拍才出招——对音刃这种远程投射物,可以让动画前段先演出、投射物在结算点生成来掩盖。这个点务必和策划当面对齐,它直接影响手感验收标准 17.1 的"按下后立即出动作"在 Space 上如何解释。
|
||||||
|
|
||||||
|
**StateMachine 直接照抄 3.2 节的八个状态**,每个 ActionData 的 `required_state` 字段由状态机门控(W 派生只在 air 状态可解析,招架结算只在 guarding)。charging、bladeChain、burstCharge 这三个状态的本质是"改变 Space 解析结果的模式",所以 ActionResolver 每次解析都要先问状态机当前状态——优先级链的①②③本质上就是状态查询。
|
||||||
|
|
||||||
|
**MotionExecutor 是这个游戏区别于普通横版的组件。** 玩家不能自由走路,意味着 Player 里**不存在**常规的"读方向键改 velocity"代码;所有位移都是动作的产物:接到一个动作的 `FinalMoveX/Y`(CombatManager 算好的乘区结果),在 `action_beats × beat_time` 的时长内用 tween 或速度曲线执行完,期间仍走 `move_and_slide` 保证碰撞。W 的 T0–T1 上升、T1 高点、T1–T2 下落也由它按拍切分,并在 T1 通知状态机开放空中派生窗口。
|
||||||
|
|
||||||
|
**资源三件套(Energy / Buff / Burst)全部是 EventBus 的订阅者。** EnergyComponent 的空挥限制需要一条此前没有的反馈链:DamageEmitter 命中时经 CombatManager 广播 `hit_confirmed`,EnergyComponent 据此区分"命中回能 100% / 有效位移未命中 50% / 连续三次空挥后归零"。BuffContainer 监听判定流水(合拍、完美律动看连击流)、连段事件(强袭乐句看 AAA 终结)、招架结果(守拍反击),持续时间订阅 `beat_ticked` 递减,并暴露一个 `ticking_paused` 开关给爆气用。BurstComponent 自己是个小状态机(off/ready/active/cooldown),每次能量或连击变化时检查 12.3 的三选一条件点亮 ready,激活时做四件事:改 CombatManager 乘区、把技能 cost 乘区归零、调 `RhythmManager.judgement_scale`、暂停 BuffContainer 计时,16 拍后统一还原并清资源。
|
||||||
|
|
||||||
|
## 四、世界层与敌人
|
||||||
|
|
||||||
|
Stage 负责装载关卡、把曲目和 BPM 交给 RhythmManager 开始播放。ActorsContainer 统一生成三种测试敌人和**音刃投射物**(音刃是远程投射物,正好落在上一轮"实体不自己 new 实体"的规则里)。敌人 = Character 基类 + DamageReceiver(带 ArmorLevel)+ 一个极简的 EnemyBrain:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# EnemyBrain: 敌人和玩家订阅同一个时钟,这是设计对称性所在
|
||||||
|
@export var data: EnemyData # beats_per_action、行为类型
|
||||||
|
func _on_beat(i: int) -> void:
|
||||||
|
if i % data.beats_per_action == 0:
|
||||||
|
_act() # 接近 / 攻击 / 远程射击
|
||||||
|
```
|
||||||
|
|
||||||
|
节奏型(1 拍、2 拍,以及未来的半拍、切分)就是 EnemyData 上的一个数字或节拍掩码,15.1 节的扩展方向零成本预留。破霸体走 DamageContext:W 派生的 ActionData 带 `armor_level` 和 `special = &"armor_break"`,Receiver 拿自己的 ArmorLevel 比较后决定是硬直、浮空还是只吃伤害。
|
||||||
|
|
||||||
|
UI 层强烈建议第一版就做**四槽窗口可视化**(四个格子实时显示 A/D/W/S/Space/Ø),它同时是玩家理解连段系统的核心界面和你调试 ComboWindow 的工具。这里的 Ø 只代表显式 Miss 反馈,不是空拍占位;UI 监听 ComboWindow 的信号即可。
|
||||||
|
|
||||||
|
## 五、目录结构与实施映射
|
||||||
|
|
||||||
|
```
|
||||||
|
res://
|
||||||
|
├── autoload/ # rhythm_manager.gd / event_bus.gd / combat_manager.gd
|
||||||
|
├── resources/
|
||||||
|
│ ├── actions/ # 30+ 个 ActionData .tres(基础动作与技能同构)
|
||||||
|
│ ├── buffs/ # 4 个 BuffData
|
||||||
|
│ ├── enemies/ # EnemyData(节奏型、数值)
|
||||||
|
│ └── rhythm_config.tres
|
||||||
|
├── components/ # input / combo_window / resolver / motion / energy / buff / burst / damage_*
|
||||||
|
├── scenes/
|
||||||
|
│ ├── characters/ # player.tscn、enemy 基类、三种测试敌人
|
||||||
|
│ ├── stage/ # stage.tscn、actors_container.tscn
|
||||||
|
│ ├── combat/ # 音刃投射物等
|
||||||
|
│ └── ui/ # hud.tscn、combo_slots.tscn、energy_bar.tscn
|
||||||
|
└── tests/ # 对 ComboWindow / Resolver / CombatManager 的纯逻辑单元测试
|
||||||
|
```
|
||||||
|
|
||||||
|
对应策划书 16 节的五个阶段:阶段 1 = RhythmManager + RhythmConfig + InputComponent + ComboWindow(这四个是地基,而且全部可脱离画面单元测试);阶段 2 = StateMachine + MotionExecutor + 基础动作的 ActionData;阶段 3 = ActionResolver 优先级链 + 全部技能资源 + DamageEmitter/投射物 + ActorsContainer;阶段 4 = Energy/Buff/Burst 三组件 + 乘区管线补全;阶段 5 = EnemyBrain + 三种敌人。每个阶段结束,对应的验收标准(17 节)恰好都有明确的架构归属可以逐条勾选。
|
||||||
|
|
||||||
|
## 六、需要回去和策划确认的三个洞
|
||||||
|
|
||||||
|
架构做到这里,策划书有三处未定义的行为必须补上,否则程序只能自行拍板:一是**判定阈值的具体数字**(Perfect/Good/Bad 各多少毫秒或多少比例拍长),RhythmConfig 等着填;二是上面说的**长按阈值与短按结算时机**;三是**同一拍内的第二次输入**如何处理——ComboWindow 不再按拍自动补槽,但如果玩家在同一拍内按了两次,两次输入是否都独立判定、是否都进入窗口、第二次是否直接按 Miss 处理,仍需要定义。蓄力/爆气长按期间不补 Ø;这段时间窗口应冻结或在释放后按动作规则清空。
|
||||||
|
|
||||||
|
整体看,这份策划书和这套架构咬合得相当好——因为它本质上已经是一份"以拍为时基、以数据表驱动"的设计,你要做的就是让代码结构诚实地反映这个事实:一个时钟(RhythmManager)、一张表(ActionData 资源集)、一条链(Resolver 优先级)、一条管线(乘区结算),其余全是订阅者。如果你想,下一步我可以把阶段 1 的四个地基模块(含单元测试)完整写出来。
|
||||||
@@ -17,6 +17,9 @@ config/icon="res://icon.svg"
|
|||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
EventBus="*res://autoload/event_bus.gd"
|
||||||
|
RhythmManager="*res://autoload/rhythm_manager.gd"
|
||||||
|
CombatManager="*res://autoload/combat_manager.gd"
|
||||||
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
|
MCPScreenshot="*res://addons/godot_mcp/mcp_screenshot_service.gd"
|
||||||
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
|
MCPInputService="*res://addons/godot_mcp/mcp_input_service.gd"
|
||||||
MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd"
|
MCPGameInspector="*res://addons/godot_mcp/mcp_game_inspector_service.gd"
|
||||||
@@ -27,11 +30,49 @@ enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg")
|
|||||||
|
|
||||||
[input]
|
[input]
|
||||||
|
|
||||||
jump={
|
move_left={
|
||||||
"deadzone": 0.5,
|
"deadzone": 0.2,
|
||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
])
|
||||||
}
|
}
|
||||||
|
move_right={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
combo_w={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
combo_a={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
combo_s={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
combo_space={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
combo_d={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": Array[InputEvent]([Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
[layer_names]
|
||||||
|
|
||||||
|
2d_physics/layer_1="world"
|
||||||
|
2d_physics/layer_2="player_hurtbox"
|
||||||
|
2d_physics/layer_3="enemy_hurtbox"
|
||||||
|
2d_physics/layer_4="player_hitbox"
|
||||||
|
2d_physics/layer_5="enemy_hitbox"
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
|||||||
BIN
resource/.DS_Store
vendored
BIN
resource/.DS_Store
vendored
Binary file not shown.
33
resources/action_data.gd
Normal file
33
resources/action_data.gd
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
class_name ActionData
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var id: StringName
|
||||||
|
@export var display_name: String
|
||||||
|
@export var input_pattern: Array[StringName] = []
|
||||||
|
@export var required_state: StringName = &"any"
|
||||||
|
@export var base_cost := 0.0
|
||||||
|
@export var damage_mult := 1.0
|
||||||
|
@export var move_mult_x := 0.0
|
||||||
|
@export var move_mult_y := 0.0
|
||||||
|
@export var action_beats := 1.0
|
||||||
|
@export var hit_type: StringName = &"melee"
|
||||||
|
@export var range := 0.0
|
||||||
|
@export var target_type: StringName = &"single"
|
||||||
|
@export var armor_level := 0
|
||||||
|
@export var clear_window := true
|
||||||
|
@export var can_chain := false
|
||||||
|
@export var special: StringName = &""
|
||||||
|
@export var startup_beats := 0.25
|
||||||
|
@export var active_beats := 0.25
|
||||||
|
@export var recovery_beats := 0.5
|
||||||
|
@export_range(0.0, 1.0, 0.05) var cancel_from := 0.5
|
||||||
|
|
||||||
|
@export var animation: StringName
|
||||||
|
@export var energy_cost := 0.0
|
||||||
|
@export var energy_reward := 0.0
|
||||||
|
@export var spawns_projectile := false
|
||||||
|
@export var projectile_scene: PackedScene
|
||||||
|
@export var damage := 10
|
||||||
|
@export var cancel_window := Vector2(0.3, 0.6)
|
||||||
|
@export var has_super_armor := false
|
||||||
|
@export var displacement: StringName = &""
|
||||||
1
resources/action_data.gd.uid
Normal file
1
resources/action_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cooudhoob8dn4
|
||||||
17
resources/actions/skill_a.tres
Normal file
17
resources/actions/skill_a.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_a"
|
||||||
|
display_name = "A"
|
||||||
|
input_pattern = Array[StringName]([&"A"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_a_space.tres
Normal file
16
resources/actions/skill_a_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_a_space"
|
||||||
|
display_name = "A+SP"
|
||||||
|
input_pattern = Array[StringName]([&"A", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space"
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = true
|
||||||
17
resources/actions/skill_aa.tres
Normal file
17
resources/actions/skill_aa.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_aa"
|
||||||
|
display_name = "A+A"
|
||||||
|
input_pattern = Array[StringName]([&"A", &"A"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_aa"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_aa_space.tres
Normal file
16
resources/actions/skill_aa_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_aa_space"
|
||||||
|
display_name = "A+A+SP"
|
||||||
|
input_pattern = Array[StringName]([&"A", &"A", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space_space"
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = true
|
||||||
17
resources/actions/skill_aaa.tres
Normal file
17
resources/actions/skill_aaa.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_aaa"
|
||||||
|
display_name = "A+A+A"
|
||||||
|
input_pattern = Array[StringName]([&"A", &"A", &"A"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_aaa"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_ad_space.tres
Normal file
16
resources/actions/skill_ad_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_ad_space"
|
||||||
|
display_name = "A+D+SP"
|
||||||
|
input_pattern = Array[StringName]([&"A", &"D", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space_space"
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = true
|
||||||
17
resources/actions/skill_d.tres
Normal file
17
resources/actions/skill_d.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_d"
|
||||||
|
display_name = "D"
|
||||||
|
input_pattern = Array[StringName]([&"D"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_d_space.tres
Normal file
16
resources/actions/skill_d_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_d_space"
|
||||||
|
display_name = "D+SP"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space"
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = true
|
||||||
16
resources/actions/skill_d_space_space.tres
Normal file
16
resources/actions/skill_d_space_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_d_space_space"
|
||||||
|
display_name = "D+SP+SP"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"SP", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space_space"
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = true
|
||||||
16
resources/actions/skill_da_space.tres
Normal file
16
resources/actions/skill_da_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_da_space"
|
||||||
|
display_name = "D+A+SP"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"A", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space_space"
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = true
|
||||||
17
resources/actions/skill_dd.tres
Normal file
17
resources/actions/skill_dd.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_dd"
|
||||||
|
display_name = "D+D"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"D"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_aa"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_dd_space.tres
Normal file
16
resources/actions/skill_dd_space.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_dd_space"
|
||||||
|
display_name = "D+D+SP"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"D", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_a_space_space"
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = true
|
||||||
17
resources/actions/skill_ddd.tres
Normal file
17
resources/actions/skill_ddd.tres
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_ddd"
|
||||||
|
display_name = "D+D+D"
|
||||||
|
input_pattern = Array[StringName]([&"D", &"D", &"D"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_aaa"
|
||||||
|
energy_reward = 1.0
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = false
|
||||||
15
resources/actions/skill_s.tres
Normal file
15
resources/actions/skill_s.tres
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s"
|
||||||
|
display_name = "S"
|
||||||
|
input_pattern = Array[StringName]([&"S"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s"
|
||||||
|
clear_window = false
|
||||||
20
resources/actions/skill_s_projectile_1.tres
Normal file
20
resources/actions/skill_s_projectile_1.tres
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s_projectile_1"
|
||||||
|
display_name = "S+SP"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s_projectile"
|
||||||
|
energy_cost = 3.0
|
||||||
|
spawns_projectile = true
|
||||||
|
projectile_scene = ExtResource("2")
|
||||||
|
clear_window = false
|
||||||
|
can_chain = true
|
||||||
20
resources/actions/skill_s_projectile_2.tres
Normal file
20
resources/actions/skill_s_projectile_2.tres
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s_projectile_2"
|
||||||
|
display_name = "S+SP+SP"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"SP", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s_projectile"
|
||||||
|
energy_cost = 2.0
|
||||||
|
spawns_projectile = true
|
||||||
|
projectile_scene = ExtResource("2")
|
||||||
|
clear_window = false
|
||||||
|
can_chain = true
|
||||||
20
resources/actions/skill_s_projectile_3.tres
Normal file
20
resources/actions/skill_s_projectile_3.tres
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/combat/player_projectile.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s_projectile_3"
|
||||||
|
display_name = "S+SP+SP+SP"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"SP", &"SP", &"SP"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s_projectile"
|
||||||
|
energy_cost = 1.0
|
||||||
|
spawns_projectile = true
|
||||||
|
projectile_scene = ExtResource("2")
|
||||||
|
clear_window = false
|
||||||
|
can_chain = true
|
||||||
15
resources/actions/skill_ss.tres
Normal file
15
resources/actions/skill_ss.tres
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s"
|
||||||
|
display_name = "S"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"S"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s"
|
||||||
|
clear_window = false
|
||||||
15
resources/actions/skill_sss.tres
Normal file
15
resources/actions/skill_sss.tres
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s"
|
||||||
|
display_name = "S"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"S", &"S"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s"
|
||||||
|
clear_window = false
|
||||||
15
resources/actions/skill_ssss.tres
Normal file
15
resources/actions/skill_ssss.tres
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_s"
|
||||||
|
display_name = "S"
|
||||||
|
input_pattern = Array[StringName]([&"S", &"S", &"S", &"S"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_s"
|
||||||
|
clear_window = false
|
||||||
15
resources/actions/skill_w.tres
Normal file
15
resources/actions/skill_w.tres
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_w"
|
||||||
|
display_name = "W"
|
||||||
|
input_pattern = Array[StringName]([&"W"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_w"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_wa.tres
Normal file
16
resources/actions/skill_wa.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_wa"
|
||||||
|
display_name = "W+A"
|
||||||
|
input_pattern = Array[StringName]([&"W", &"A"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_wa"
|
||||||
|
displacement = &"left"
|
||||||
|
clear_window = false
|
||||||
16
resources/actions/skill_wd.tres
Normal file
16
resources/actions/skill_wd.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="ActionData" load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://resources/action_data.gd" id="1"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
id = &"skill_wd"
|
||||||
|
display_name = "W+D"
|
||||||
|
input_pattern = Array[StringName]([&"W", &"D"])
|
||||||
|
startup_beats = 0.25
|
||||||
|
active_beats = 0.25
|
||||||
|
recovery_beats = 0.5
|
||||||
|
cancel_from = 0.5
|
||||||
|
animation = &"warrior_wa"
|
||||||
|
displacement = &"right"
|
||||||
|
clear_window = false
|
||||||
27
resources/beat_chart.gd
Normal file
27
resources/beat_chart.gd
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
class_name BeatChart
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var chart_id: StringName = &""
|
||||||
|
@export var total_beats := 0
|
||||||
|
@export var tracks: Array = []
|
||||||
|
|
||||||
|
|
||||||
|
func all_events() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for track in tracks:
|
||||||
|
if not track is Resource:
|
||||||
|
continue
|
||||||
|
for event in track.call("sorted_events"):
|
||||||
|
result.append(event)
|
||||||
|
result.sort_custom(func(a: Resource, b: Resource) -> bool:
|
||||||
|
var a_position := float(a.call("beat_position"))
|
||||||
|
var b_position := float(b.call("beat_position"))
|
||||||
|
if is_equal_approx(a_position, b_position):
|
||||||
|
return str(a.get("event_type")) < str(b.get("event_type"))
|
||||||
|
return a_position < b_position
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func is_empty() -> bool:
|
||||||
|
return all_events().is_empty()
|
||||||
1
resources/beat_chart.gd.uid
Normal file
1
resources/beat_chart.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b0jn7bu4w1co7
|
||||||
26
resources/chart_event.gd
Normal file
26
resources/chart_event.gd
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class_name ChartEvent
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var event_id: StringName = &""
|
||||||
|
@export var beat_index := 0
|
||||||
|
@export var subdivision := 0
|
||||||
|
@export var subdivisions_per_beat := 1
|
||||||
|
@export var event_type: StringName = &""
|
||||||
|
@export var target_id: StringName = &""
|
||||||
|
@export var payload: Dictionary = {}
|
||||||
|
@export var lead_beats := 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func beat_position() -> float:
|
||||||
|
var safe_subdivisions := maxi(1, subdivisions_per_beat)
|
||||||
|
return float(beat_index) + float(subdivision) / float(safe_subdivisions)
|
||||||
|
|
||||||
|
|
||||||
|
func time_seconds(beat_time: float) -> float:
|
||||||
|
return beat_position() * maxf(0.001, beat_time)
|
||||||
|
|
||||||
|
|
||||||
|
func key() -> StringName:
|
||||||
|
if not event_id.is_empty():
|
||||||
|
return event_id
|
||||||
|
return StringName("%s:%s:%d:%d" % [event_type, target_id, beat_index, subdivision])
|
||||||
1
resources/chart_event.gd.uid
Normal file
1
resources/chart_event.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bsbmwl7n6uil5
|
||||||
17
resources/chart_track.gd
Normal file
17
resources/chart_track.gd
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
class_name ChartTrack
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
@export var track_id: StringName = &""
|
||||||
|
@export var track_type: StringName = &""
|
||||||
|
@export var events: Array = []
|
||||||
|
|
||||||
|
|
||||||
|
func sorted_events() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for event in events:
|
||||||
|
if event is Resource:
|
||||||
|
result.append(event)
|
||||||
|
result.sort_custom(func(a: Resource, b: Resource) -> bool:
|
||||||
|
return float(a.call("beat_position")) < float(b.call("beat_position"))
|
||||||
|
)
|
||||||
|
return result
|
||||||
1
resources/chart_track.gd.uid
Normal file
1
resources/chart_track.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://t7etuk7uw3ns
|
||||||
BIN
scenes/.DS_Store
vendored
BIN
scenes/.DS_Store
vendored
Binary file not shown.
@@ -8,18 +8,22 @@ const GRAVITY := 1200.0
|
|||||||
@export var attack_duration := 0.4
|
@export var attack_duration := 0.4
|
||||||
@export var attack_lunge_duration := 0.18
|
@export var attack_lunge_duration := 0.18
|
||||||
@export var attack_lunge_speed := 220.0
|
@export var attack_lunge_speed := 220.0
|
||||||
|
@export var air_attack_duration := 0.45
|
||||||
|
@export var air_attack_lunge_duration := 0.22
|
||||||
|
@export var air_attack_lunge_speed := 260.0
|
||||||
|
|
||||||
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
@onready var animation_player: AnimationPlayer = $AnimationPlayer
|
||||||
@onready var character_sprite: Sprite2D = $CharacterSprite
|
@onready var character_sprite: Sprite2D = $CharacterSprite
|
||||||
|
|
||||||
enum State { IDLE, WALK, JUMP, LAND, ATTACK }
|
enum State { IDLE, WALK, JUMP, LAND, ATTACK, AIR_ATTACK }
|
||||||
|
|
||||||
var anim_map := {
|
var anim_map := {
|
||||||
State.IDLE: "idle",
|
State.IDLE: "warrior_idle",
|
||||||
State.WALK: "idle",
|
State.WALK: "warrior_idle",
|
||||||
State.JUMP: "jump",
|
State.JUMP: "warrior_w",
|
||||||
State.LAND: "idle",
|
State.LAND: "warrior_idle",
|
||||||
State.ATTACK: "挥砍",
|
State.ATTACK: "warrior_a",
|
||||||
|
State.AIR_ATTACK: "warrior_a",
|
||||||
}
|
}
|
||||||
var attack_direction := Vector2.RIGHT
|
var attack_direction := Vector2.RIGHT
|
||||||
var attack_lunge_time_left := 0.0
|
var attack_lunge_time_left := 0.0
|
||||||
@@ -55,7 +59,7 @@ func handle_air_time(delta: float) -> void:
|
|||||||
height_speed -= GRAVITY * delta
|
height_speed -= GRAVITY * delta
|
||||||
|
|
||||||
func handle_attack_time(delta: float) -> void:
|
func handle_attack_time(delta: float) -> void:
|
||||||
if state != State.ATTACK:
|
if state != State.ATTACK and state != State.AIR_ATTACK:
|
||||||
return
|
return
|
||||||
velocity.y = 0.0
|
velocity.y = 0.0
|
||||||
attack_time_left -= delta
|
attack_time_left -= delta
|
||||||
@@ -68,7 +72,7 @@ func handle_attack_time(delta: float) -> void:
|
|||||||
velocity.x = 0.0
|
velocity.x = 0.0
|
||||||
|
|
||||||
func handle_movement() -> void:
|
func handle_movement() -> void:
|
||||||
if state == State.JUMP or state == State.ATTACK:
|
if state == State.JUMP or state == State.ATTACK or state == State.AIR_ATTACK:
|
||||||
return
|
return
|
||||||
if absf(velocity.x) > 0.0:
|
if absf(velocity.x) > 0.0:
|
||||||
state = State.WALK
|
state = State.WALK
|
||||||
@@ -97,6 +101,9 @@ func can_jump() -> bool:
|
|||||||
func can_attack() -> bool:
|
func can_attack() -> bool:
|
||||||
return state == State.IDLE or state == State.WALK
|
return state == State.IDLE or state == State.WALK
|
||||||
|
|
||||||
|
func can_air_attack() -> bool:
|
||||||
|
return state == State.IDLE or state == State.WALK
|
||||||
|
|
||||||
func start_jump() -> void:
|
func start_jump() -> void:
|
||||||
state = State.JUMP
|
state = State.JUMP
|
||||||
height_speed = jump_intensity
|
height_speed = jump_intensity
|
||||||
@@ -112,3 +119,25 @@ func start_directional_attack(direction: Vector2) -> void:
|
|||||||
attack_time_left = attack_duration
|
attack_time_left = attack_duration
|
||||||
attack_lunge_time_left = attack_lunge_duration
|
attack_lunge_time_left = attack_lunge_duration
|
||||||
velocity = Vector2(attack_x * attack_lunge_speed, 0.0)
|
velocity = Vector2(attack_x * attack_lunge_speed, 0.0)
|
||||||
|
|
||||||
|
func start_air_attack() -> void:
|
||||||
|
start_directional_air_attack(heading)
|
||||||
|
|
||||||
|
func start_directional_air_attack(direction: Vector2) -> void:
|
||||||
|
var attack_x := -1.0 if direction.x < 0.0 else 1.0
|
||||||
|
attack_direction = Vector2(attack_x, 0.0)
|
||||||
|
heading = Vector2.RIGHT if attack_x > 0.0 else Vector2.LEFT
|
||||||
|
state = State.AIR_ATTACK
|
||||||
|
attack_time_left = air_attack_duration
|
||||||
|
attack_lunge_time_left = air_attack_lunge_duration
|
||||||
|
velocity = Vector2(attack_x * air_attack_lunge_speed, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func begin_attack_motion(duration: float, next_velocity: Vector2) -> void:
|
||||||
|
attack_lunge_time_left = maxf(0.0, duration)
|
||||||
|
velocity = next_velocity
|
||||||
|
|
||||||
|
|
||||||
|
func stop_attack_motion() -> void:
|
||||||
|
attack_lunge_time_left = 0.0
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
|||||||
@@ -1,23 +1,78 @@
|
|||||||
class_name Player
|
class_name Player
|
||||||
extends Character
|
extends Character
|
||||||
|
|
||||||
|
signal combo_window_changed(slots: Array)
|
||||||
|
signal combo_window_cleared(reason: String)
|
||||||
|
signal charge_changed(current: float, maximum: float, ready: bool, active: bool)
|
||||||
|
signal energy_changed(current: int, maximum: int)
|
||||||
|
signal health_changed(current: int, maximum: int)
|
||||||
|
signal skill_requested(skill_id: String)
|
||||||
|
signal projectile_requested(projectile_scene: PackedScene, spawn_position: Vector2, direction: Vector2)
|
||||||
|
|
||||||
|
@export var combo_clear_display_time := 0.35
|
||||||
|
@export var max_health := 100
|
||||||
|
@export var current_health := 100
|
||||||
|
@export var max_energy := 10
|
||||||
|
@export var current_energy := 0
|
||||||
|
|
||||||
|
@onready var state_machine: Node = $StateMachine
|
||||||
|
@onready var input_component: Node = $InputComponent
|
||||||
|
@onready var combo_window: Node = $ComboWindow
|
||||||
|
@onready var action_controller: Node = $ActionController
|
||||||
|
@onready var motion_executor: Node = $MotionExecutor
|
||||||
|
@onready var burst_component: Node = $BurstComponent
|
||||||
|
@onready var charge_component: Node = $ChargeComponent
|
||||||
|
@onready var energy_component: Node = $EnergyComponent
|
||||||
|
@onready var health_component: Node = $HealthComponent
|
||||||
|
@onready var damage_receiver: Area2D = $DamageReceiver
|
||||||
|
|
||||||
|
var last_requested_skill_id := ""
|
||||||
|
var current_skill_animation := ""
|
||||||
|
var _held_symbols: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
combo_window.clear_display_time = combo_clear_display_time
|
||||||
|
input_component.intent_created.connect(_on_input_intent_created)
|
||||||
|
action_controller.action_started.connect(_on_action_started)
|
||||||
|
action_controller.action_active_started.connect(_on_action_active_started)
|
||||||
|
action_controller.action_finished.connect(_on_action_finished)
|
||||||
|
action_controller.action_rejected.connect(_on_action_rejected)
|
||||||
|
combo_window.combo_updated.connect(_on_combo_updated)
|
||||||
|
combo_window.combo_cleared.connect(_on_combo_cleared)
|
||||||
|
charge_component.charge_changed.connect(_on_charge_component_changed)
|
||||||
|
charge_component.release_requested.connect(_execute_charge_release)
|
||||||
|
energy_component.energy_changed.connect(_on_energy_component_changed)
|
||||||
|
health_component.health_changed.connect(_on_health_component_changed)
|
||||||
|
damage_receiver.damage_received.connect(_on_damage_received)
|
||||||
|
energy_component.set_values(current_energy, max_energy)
|
||||||
|
health_component.set_values(current_health, max_health)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
charge_component.tick(delta, state == State.IDLE)
|
||||||
|
if charge_component.is_active():
|
||||||
|
state = State.IDLE
|
||||||
|
attack_time_left = 0.0
|
||||||
|
stop_attack_motion()
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if input_component.handle_input_event(event):
|
||||||
|
_mark_input_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if input_component.handle_input_event(event):
|
||||||
|
_mark_input_handled()
|
||||||
|
|
||||||
|
|
||||||
func handle_input() -> void:
|
func handle_input() -> void:
|
||||||
if Input.is_action_just_pressed("ui_left"):
|
if charge_component.is_active():
|
||||||
judge_rhythm_action("left")
|
velocity = Vector2.ZERO
|
||||||
if can_attack():
|
|
||||||
start_directional_attack(Vector2.LEFT)
|
|
||||||
return
|
return
|
||||||
if Input.is_action_just_pressed("ui_right"):
|
_apply_horizontal_movement()
|
||||||
judge_rhythm_action("right")
|
|
||||||
if can_attack():
|
|
||||||
start_directional_attack(Vector2.RIGHT)
|
|
||||||
return
|
|
||||||
if Input.is_action_just_pressed("jump"):
|
|
||||||
judge_rhythm_action("jump")
|
|
||||||
if can_jump():
|
|
||||||
start_jump()
|
|
||||||
if state == State.IDLE or state == State.WALK:
|
|
||||||
velocity.x = 0.0
|
|
||||||
|
|
||||||
func set_heading() -> void:
|
func set_heading() -> void:
|
||||||
if velocity.x > 0.0:
|
if velocity.x > 0.0:
|
||||||
@@ -25,7 +80,274 @@ func set_heading() -> void:
|
|||||||
elif velocity.x < 0.0:
|
elif velocity.x < 0.0:
|
||||||
heading = Vector2.LEFT
|
heading = Vector2.LEFT
|
||||||
|
|
||||||
func judge_rhythm_action(action_name: String) -> void:
|
|
||||||
var conductor: Node = get_tree().get_first_node_in_group("rhythm_conductor")
|
func get_combo_slots() -> Array[StringName]:
|
||||||
if conductor != null and conductor.has_method("judge_action"):
|
return combo_window.get_slots()
|
||||||
conductor.call("judge_action", action_name)
|
|
||||||
|
|
||||||
|
func get_energy() -> int:
|
||||||
|
current_energy = energy_component.current
|
||||||
|
return current_energy
|
||||||
|
|
||||||
|
|
||||||
|
func get_max_energy() -> int:
|
||||||
|
max_energy = energy_component.maximum
|
||||||
|
return max_energy
|
||||||
|
|
||||||
|
|
||||||
|
func get_health() -> int:
|
||||||
|
current_health = health_component.current
|
||||||
|
return current_health
|
||||||
|
|
||||||
|
|
||||||
|
func get_max_health() -> int:
|
||||||
|
max_health = health_component.maximum
|
||||||
|
return max_health
|
||||||
|
|
||||||
|
|
||||||
|
func get_charge() -> float:
|
||||||
|
return charge_component.value
|
||||||
|
|
||||||
|
|
||||||
|
func get_max_charge() -> float:
|
||||||
|
return charge_component.maximum()
|
||||||
|
|
||||||
|
|
||||||
|
func is_charge_active() -> bool:
|
||||||
|
return charge_component.is_active()
|
||||||
|
|
||||||
|
|
||||||
|
func is_charge_ready() -> bool:
|
||||||
|
return charge_component.is_ready()
|
||||||
|
|
||||||
|
|
||||||
|
func submit_combo_input(symbol: String, forced_rating := "") -> String:
|
||||||
|
var data := _symbol_to_intent_data(symbol)
|
||||||
|
if data.is_empty():
|
||||||
|
return ""
|
||||||
|
var intent: RefCounted = load("res://scenes/components/input_intent.gd").create(data["symbol"], data["rhythm_action"], &"pressed", float(Time.get_ticks_msec()))
|
||||||
|
if not forced_rating.is_empty():
|
||||||
|
intent.judgement = _rating_result(StringName(forced_rating), 0.0)
|
||||||
|
action_controller.submit_intent(intent)
|
||||||
|
return last_requested_skill_id
|
||||||
|
|
||||||
|
|
||||||
|
func _symbol_to_intent_data(symbol: String) -> Dictionary:
|
||||||
|
match symbol:
|
||||||
|
"W":
|
||||||
|
return {"symbol": &"W", "rhythm_action": &"w"}
|
||||||
|
"A":
|
||||||
|
return {"symbol": &"A", "rhythm_action": &"a"}
|
||||||
|
"D":
|
||||||
|
return {"symbol": &"D", "rhythm_action": &"d"}
|
||||||
|
"S":
|
||||||
|
return {"symbol": &"S", "rhythm_action": &"s"}
|
||||||
|
"SP":
|
||||||
|
return {"symbol": &"SP", "rhythm_action": &"space"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func flush_pending_combo_clear() -> void:
|
||||||
|
combo_window.flush_pending_clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _play_skill_animation(animation_name: String, displacement_direction := Vector2.ZERO, skill: Resource = null) -> void:
|
||||||
|
current_skill_animation = animation_name
|
||||||
|
anim_map[State.ATTACK] = animation_name
|
||||||
|
state = State.ATTACK
|
||||||
|
state_machine.change_state(state)
|
||||||
|
attack_time_left = _animation_length(animation_name)
|
||||||
|
if displacement_direction == Vector2.ZERO:
|
||||||
|
stop_attack_motion()
|
||||||
|
else:
|
||||||
|
heading = displacement_direction
|
||||||
|
if skill != null:
|
||||||
|
motion_executor.execute(skill, displacement_direction, _rhythm_beat_time(), attack_lunge_speed)
|
||||||
|
attack_time_left = motion_executor.duration
|
||||||
|
begin_attack_motion(motion_executor.duration, motion_executor.velocity)
|
||||||
|
if animation_player != null and animation_player.has_animation(animation_name):
|
||||||
|
animation_player.play(animation_name)
|
||||||
|
|
||||||
|
|
||||||
|
func _skill_displacement_direction(skill: Resource) -> Vector2:
|
||||||
|
match StringName(str(skill.get("displacement"))):
|
||||||
|
&"left":
|
||||||
|
return Vector2.LEFT
|
||||||
|
&"right":
|
||||||
|
return Vector2.RIGHT
|
||||||
|
return Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func _begin_charge_hold(symbol: StringName, direction: Vector2) -> void:
|
||||||
|
charge_component.begin_hold(symbol, direction)
|
||||||
|
|
||||||
|
|
||||||
|
func _finish_charge_hold(symbol: StringName) -> void:
|
||||||
|
charge_component.finish_hold(symbol)
|
||||||
|
|
||||||
|
|
||||||
|
func _execute_charge_release(skill_id: StringName, direction: Vector2) -> void:
|
||||||
|
last_requested_skill_id = str(skill_id)
|
||||||
|
current_skill_animation = "warrior_charge_release"
|
||||||
|
skill_requested.emit(last_requested_skill_id)
|
||||||
|
_play_skill_animation(current_skill_animation, direction)
|
||||||
|
|
||||||
|
|
||||||
|
func _cancel_missed_direction_action() -> void:
|
||||||
|
stop_attack_motion()
|
||||||
|
attack_time_left = 0.0
|
||||||
|
state = State.IDLE
|
||||||
|
current_skill_animation = "warrior_idle"
|
||||||
|
if animation_player != null and animation_player.has_animation("warrior_idle"):
|
||||||
|
animation_player.play("warrior_idle")
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_horizontal_movement() -> void:
|
||||||
|
if state != State.IDLE and state != State.WALK:
|
||||||
|
return
|
||||||
|
var direction: float = input_component.get_horizontal_axis()
|
||||||
|
if direction < 0.0:
|
||||||
|
heading = Vector2.LEFT
|
||||||
|
elif direction > 0.0:
|
||||||
|
heading = Vector2.RIGHT
|
||||||
|
velocity.x = direction * speed
|
||||||
|
|
||||||
|
|
||||||
|
func _animation_length(animation_name: String) -> float:
|
||||||
|
if animation_player != null and animation_player.has_animation(animation_name):
|
||||||
|
return maxf(0.1, animation_player.get_animation(animation_name).length)
|
||||||
|
return attack_duration
|
||||||
|
|
||||||
|
|
||||||
|
func _request_projectile(skill: Resource) -> void:
|
||||||
|
var spawn_position := global_position + Vector2(heading.x * 36.0, -30.0)
|
||||||
|
var projectile_scene := skill.get("projectile_scene") as PackedScene
|
||||||
|
projectile_requested.emit(projectile_scene, spawn_position, heading)
|
||||||
|
_event_bus().emit_signal("projectile_requested", projectile_scene, spawn_position, heading)
|
||||||
|
|
||||||
|
|
||||||
|
func _rhythm_beat_time() -> float:
|
||||||
|
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||||
|
if rhythm != null:
|
||||||
|
return float(rhythm.get("beat_time"))
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
func _on_input_intent_created(intent) -> void:
|
||||||
|
if intent.is_pressed():
|
||||||
|
_held_symbols[intent.symbol] = true
|
||||||
|
elif intent.is_released():
|
||||||
|
_held_symbols.erase(intent.symbol)
|
||||||
|
action_controller.submit_intent(intent)
|
||||||
|
if intent.is_pressed() and (intent.symbol == &"A" or intent.symbol == &"D"):
|
||||||
|
input_component.set_direction_suppressed(intent.symbol, true)
|
||||||
|
if intent.is_released() and (intent.symbol == &"A" or intent.symbol == &"D"):
|
||||||
|
input_component.set_direction_suppressed(intent.symbol, false)
|
||||||
|
_finish_charge_hold(intent.symbol)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_started(action: Resource, intent) -> void:
|
||||||
|
current_energy = energy_component.current
|
||||||
|
last_requested_skill_id = str(action.get("id"))
|
||||||
|
current_skill_animation = str(action.get("animation"))
|
||||||
|
skill_requested.emit(last_requested_skill_id)
|
||||||
|
var displacement_direction := _skill_displacement_direction(action)
|
||||||
|
if displacement_direction != Vector2.ZERO:
|
||||||
|
heading = displacement_direction
|
||||||
|
_play_skill_animation(current_skill_animation)
|
||||||
|
if intent.is_pressed() and _is_symbol_held(intent.symbol) and (intent.symbol == &"A" or intent.symbol == &"D"):
|
||||||
|
_begin_charge_hold(intent.symbol, Vector2.LEFT if intent.symbol == &"A" else Vector2.RIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_active_started(action: Resource, intent) -> void:
|
||||||
|
current_energy = energy_component.current
|
||||||
|
_event_bus().emit_signal("skill_executed", action, StringName(str(intent.judgement.get("label", "perfect"))))
|
||||||
|
_start_skill_motion(action)
|
||||||
|
if bool(action.get("spawns_projectile")):
|
||||||
|
_request_projectile(action)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_finished(_action: Resource) -> void:
|
||||||
|
_set_idle_presentation()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_action_rejected(intent, reason: StringName) -> void:
|
||||||
|
if reason == &"miss" and (intent.symbol == &"A" or intent.symbol == &"D"):
|
||||||
|
_cancel_missed_direction_action()
|
||||||
|
|
||||||
|
|
||||||
|
func _start_skill_motion(action: Resource) -> void:
|
||||||
|
var displacement_direction := _skill_displacement_direction(action)
|
||||||
|
if displacement_direction == Vector2.ZERO:
|
||||||
|
return
|
||||||
|
heading = displacement_direction
|
||||||
|
motion_executor.execute(action, displacement_direction, _rhythm_beat_time(), attack_lunge_speed)
|
||||||
|
attack_time_left = maxf(attack_time_left, motion_executor.duration)
|
||||||
|
begin_attack_motion(motion_executor.duration, motion_executor.velocity)
|
||||||
|
|
||||||
|
|
||||||
|
func _is_symbol_held(symbol: StringName) -> bool:
|
||||||
|
return bool(_held_symbols.get(symbol, false))
|
||||||
|
|
||||||
|
|
||||||
|
func _set_idle_presentation() -> void:
|
||||||
|
stop_attack_motion()
|
||||||
|
attack_time_left = 0.0
|
||||||
|
state = State.IDLE
|
||||||
|
state_machine.change_state(state)
|
||||||
|
current_skill_animation = "warrior_idle"
|
||||||
|
if animation_player != null and animation_player.has_animation("warrior_idle"):
|
||||||
|
animation_player.play("warrior_idle")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_combo_updated(slots: Array[StringName]) -> void:
|
||||||
|
combo_window_changed.emit(slots)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_combo_cleared(reason: StringName) -> void:
|
||||||
|
combo_window_cleared.emit(str(reason))
|
||||||
|
|
||||||
|
|
||||||
|
func _on_charge_component_changed(current: float, maximum: float, ready: bool, active: bool) -> void:
|
||||||
|
charge_changed.emit(current, maximum, ready, active)
|
||||||
|
_event_bus().emit_signal("player_charge_changed", current, maximum, ready, active)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_energy_component_changed(current: int, maximum: int) -> void:
|
||||||
|
current_energy = current
|
||||||
|
max_energy = maximum
|
||||||
|
energy_changed.emit(current, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_health_component_changed(current: int, maximum: int) -> void:
|
||||||
|
current_health = current
|
||||||
|
max_health = maximum
|
||||||
|
health_changed.emit(current, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_damage_received(amount: int, _hit_type: StringName, _from: Vector2) -> void:
|
||||||
|
health_component.apply_damage(amount)
|
||||||
|
|
||||||
|
|
||||||
|
func _rating_result(label: StringName, offset_ms: float) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"label": str(label),
|
||||||
|
"diff": offset_ms / 1000.0,
|
||||||
|
"abs_diff": absf(offset_ms / 1000.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _mark_input_handled() -> void:
|
||||||
|
var viewport := get_viewport()
|
||||||
|
if viewport != null:
|
||||||
|
viewport.set_input_as_handled()
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus() -> Node:
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
110
scenes/chart/chart_runner.gd
Normal file
110
scenes/chart/chart_runner.gd
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
class_name ChartRunner
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal chart_event_upcoming(event: Resource, time_to_event: float)
|
||||||
|
signal chart_event_triggered(event: Resource)
|
||||||
|
signal chart_reset(chart_id: StringName)
|
||||||
|
signal chart_finished(chart_id: StringName)
|
||||||
|
|
||||||
|
const UPCOMING_TIME_EPSILON := 0.02
|
||||||
|
|
||||||
|
@export var chart: Resource
|
||||||
|
@export var rhythm_manager_path: NodePath
|
||||||
|
@export var beat_time_override := 0.0
|
||||||
|
@export var auto_run := true
|
||||||
|
|
||||||
|
var running := true
|
||||||
|
var _upcoming_keys: Dictionary = {}
|
||||||
|
var _triggered_keys: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
running = auto_run
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(_delta: float) -> void:
|
||||||
|
if not running or chart == null:
|
||||||
|
return
|
||||||
|
var rhythm := _rhythm_manager()
|
||||||
|
if rhythm == null or not rhythm.has_method("song_position"):
|
||||||
|
return
|
||||||
|
update_for_song_time(float(rhythm.call("song_position")))
|
||||||
|
|
||||||
|
|
||||||
|
func set_chart(next_chart: Resource) -> void:
|
||||||
|
chart = next_chart
|
||||||
|
reset()
|
||||||
|
|
||||||
|
|
||||||
|
func reset() -> void:
|
||||||
|
_upcoming_keys.clear()
|
||||||
|
_triggered_keys.clear()
|
||||||
|
var chart_id := &""
|
||||||
|
if chart != null:
|
||||||
|
chart_id = StringName(str(chart.get("chart_id")))
|
||||||
|
chart_reset.emit(chart_id)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_reset", chart_id)
|
||||||
|
|
||||||
|
|
||||||
|
func update_for_song_time(song_time: float) -> void:
|
||||||
|
if chart == null:
|
||||||
|
return
|
||||||
|
var beat_time := _beat_time()
|
||||||
|
for event: Resource in chart.call("all_events"):
|
||||||
|
var event_time := float(event.call("time_seconds", beat_time))
|
||||||
|
var time_to_event := event_time - song_time
|
||||||
|
var lead_time := maxf(0.0, float(event.get("lead_beats"))) * beat_time
|
||||||
|
var event_key: StringName = event.call("key")
|
||||||
|
if not _upcoming_keys.has(event_key) and time_to_event > 0.0 and time_to_event <= lead_time + UPCOMING_TIME_EPSILON:
|
||||||
|
_upcoming_keys[event_key] = true
|
||||||
|
_emit_upcoming(event, time_to_event)
|
||||||
|
if not _triggered_keys.has(event_key) and song_time >= event_time:
|
||||||
|
_triggered_keys[event_key] = true
|
||||||
|
_emit_triggered(event)
|
||||||
|
|
||||||
|
|
||||||
|
func pause() -> void:
|
||||||
|
running = false
|
||||||
|
|
||||||
|
|
||||||
|
func resume() -> void:
|
||||||
|
running = true
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_upcoming(event: Resource, time_to_event: float) -> void:
|
||||||
|
chart_event_upcoming.emit(event, time_to_event)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_event_upcoming", event, time_to_event)
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_triggered(event: Resource) -> void:
|
||||||
|
chart_event_triggered.emit(event)
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal("chart_event_triggered", event)
|
||||||
|
|
||||||
|
|
||||||
|
func _beat_time() -> float:
|
||||||
|
if beat_time_override > 0.0:
|
||||||
|
return beat_time_override
|
||||||
|
var rhythm := _rhythm_manager()
|
||||||
|
if rhythm != null:
|
||||||
|
return float(rhythm.get("beat_time"))
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
func _rhythm_manager() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
if not rhythm_manager_path.is_empty():
|
||||||
|
return get_node_or_null(rhythm_manager_path)
|
||||||
|
return get_tree().root.get_node_or_null("RhythmManager")
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("EventBus")
|
||||||
1
scenes/chart/chart_runner.gd.uid
Normal file
1
scenes/chart/chart_runner.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d1st4d2h1bt1m
|
||||||
170
scenes/combat/action_resolver.gd
Normal file
170
scenes/combat/action_resolver.gd
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
class_name ActionResolver
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const ACTION_DIR := "res://resources/actions"
|
||||||
|
|
||||||
|
static var _loaded := false
|
||||||
|
static var _actions_by_pattern: Dictionary = {}
|
||||||
|
static var _actions_by_id: Dictionary = {}
|
||||||
|
static var _space_priority_labels: Array[StringName] = [
|
||||||
|
&"charge_release",
|
||||||
|
&"burst",
|
||||||
|
&"counter_projectile",
|
||||||
|
&"blade_chain",
|
||||||
|
&"state_specific",
|
||||||
|
&"exact_pattern",
|
||||||
|
&"fallback",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_window(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||||
|
return ActionResolver.resolve(window, state_machine, context)
|
||||||
|
|
||||||
|
|
||||||
|
func resolve_text_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||||
|
return ActionResolver.resolve_pattern(pattern, state_machine, context)
|
||||||
|
|
||||||
|
|
||||||
|
static func resolve(window: Variant, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||||
|
return resolve_pattern(window.get_contiguous_pattern(), state_machine, context)
|
||||||
|
|
||||||
|
|
||||||
|
static func resolve_pattern(pattern: String, state_machine: Variant = null, context: Dictionary = {}) -> Resource:
|
||||||
|
_ensure_loaded()
|
||||||
|
var key := _normalize_pattern(pattern)
|
||||||
|
if key.ends_with("SP"):
|
||||||
|
var priority_result := _resolve_space_priority(key, state_machine, context)
|
||||||
|
if priority_result != null:
|
||||||
|
return priority_result
|
||||||
|
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||||
|
var state := _state_name(state_machine, context)
|
||||||
|
for action: Resource in candidates:
|
||||||
|
if _can_use_in_state(action, state):
|
||||||
|
return action
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func get_action(action_id: StringName) -> Resource:
|
||||||
|
_ensure_loaded()
|
||||||
|
return _actions_by_id.get(action_id, null) as Resource
|
||||||
|
|
||||||
|
|
||||||
|
static func space_priority_labels() -> Array[StringName]:
|
||||||
|
return _space_priority_labels.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
static func reload(action_dir := ACTION_DIR) -> void:
|
||||||
|
_loaded = true
|
||||||
|
_actions_by_pattern.clear()
|
||||||
|
_actions_by_id.clear()
|
||||||
|
_load_dir(action_dir)
|
||||||
|
|
||||||
|
|
||||||
|
static func clear_cache() -> void:
|
||||||
|
_actions_by_pattern.clear()
|
||||||
|
_actions_by_id.clear()
|
||||||
|
_loaded = false
|
||||||
|
|
||||||
|
|
||||||
|
static func _ensure_loaded() -> void:
|
||||||
|
if not _loaded:
|
||||||
|
reload()
|
||||||
|
|
||||||
|
|
||||||
|
static func _load_dir(action_dir: String) -> void:
|
||||||
|
var dir := DirAccess.open(action_dir)
|
||||||
|
if dir == null:
|
||||||
|
return
|
||||||
|
var files := dir.get_files()
|
||||||
|
files.sort()
|
||||||
|
for file_name: String in files:
|
||||||
|
if not file_name.ends_with(".tres"):
|
||||||
|
continue
|
||||||
|
var action: Resource = load("%s/%s" % [action_dir, file_name])
|
||||||
|
if action == null or not action.get("input_pattern") is Array:
|
||||||
|
continue
|
||||||
|
_register_action(action)
|
||||||
|
|
||||||
|
|
||||||
|
static func _register_action(action: Resource) -> void:
|
||||||
|
var key := _pattern_key(action.get("input_pattern"))
|
||||||
|
if key.is_empty():
|
||||||
|
return
|
||||||
|
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||||
|
candidates.append(action)
|
||||||
|
_actions_by_pattern[key] = candidates
|
||||||
|
var id := StringName(str(action.get("id")))
|
||||||
|
if not id.is_empty():
|
||||||
|
_actions_by_id[id] = action
|
||||||
|
|
||||||
|
|
||||||
|
static func _resolve_space_priority(key: String, state_machine: Variant, context: Dictionary) -> Resource:
|
||||||
|
var state := _state_name(state_machine, context)
|
||||||
|
for action_id_key: String in [
|
||||||
|
"charge_release_action_id",
|
||||||
|
"burst_action_id",
|
||||||
|
"counter_action_id",
|
||||||
|
"blade_chain_action_id",
|
||||||
|
]:
|
||||||
|
if not context.has(action_id_key):
|
||||||
|
continue
|
||||||
|
if action_id_key == "counter_action_id" and not bool(context.get("counter_ready", false)):
|
||||||
|
continue
|
||||||
|
if action_id_key == "blade_chain_action_id" and not bool(context.get("blade_chain_active", false)):
|
||||||
|
continue
|
||||||
|
var explicit_action := get_action(StringName(str(context[action_id_key])))
|
||||||
|
if explicit_action != null and _can_use_in_state(explicit_action, state):
|
||||||
|
return explicit_action
|
||||||
|
|
||||||
|
var candidates: Array = _actions_by_pattern.get(key, [])
|
||||||
|
for action: Resource in candidates:
|
||||||
|
if _can_use_in_state(action, state):
|
||||||
|
return action
|
||||||
|
var suffix_action := _resolve_trailing_space_suffix(key, state)
|
||||||
|
if suffix_action != null:
|
||||||
|
return suffix_action
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _resolve_trailing_space_suffix(key: String, state: StringName) -> Resource:
|
||||||
|
var best_action: Resource = null
|
||||||
|
var best_length := 0
|
||||||
|
for candidate_key: String in _actions_by_pattern.keys():
|
||||||
|
if candidate_key == key:
|
||||||
|
continue
|
||||||
|
if not candidate_key.ends_with("SP"):
|
||||||
|
continue
|
||||||
|
if candidate_key.length() <= best_length:
|
||||||
|
continue
|
||||||
|
if not key.ends_with(candidate_key):
|
||||||
|
continue
|
||||||
|
for action: Resource in _actions_by_pattern.get(candidate_key, []):
|
||||||
|
if _can_use_in_state(action, state):
|
||||||
|
best_action = action
|
||||||
|
best_length = candidate_key.length()
|
||||||
|
break
|
||||||
|
return best_action
|
||||||
|
|
||||||
|
|
||||||
|
static func _pattern_key(pattern: Array[StringName]) -> String:
|
||||||
|
var key := ""
|
||||||
|
for symbol: StringName in pattern:
|
||||||
|
key += str(symbol)
|
||||||
|
return _normalize_pattern(key)
|
||||||
|
|
||||||
|
|
||||||
|
static func _normalize_pattern(pattern: String) -> String:
|
||||||
|
return pattern.replace(" ", "").to_upper()
|
||||||
|
|
||||||
|
|
||||||
|
static func _state_name(state_machine: Variant, context: Dictionary) -> StringName:
|
||||||
|
if context.has("state"):
|
||||||
|
return StringName(str(context["state"]))
|
||||||
|
if state_machine != null and state_machine.has_method("get_current_state_name"):
|
||||||
|
return StringName(str(state_machine.call("get_current_state_name")))
|
||||||
|
return &"any"
|
||||||
|
|
||||||
|
|
||||||
|
static func _can_use_in_state(action: Resource, state: StringName) -> bool:
|
||||||
|
var required := StringName(str(action.get("required_state")))
|
||||||
|
return required.is_empty() or required == &"any" or state == &"any" or required == state
|
||||||
1
scenes/combat/action_resolver.gd.uid
Normal file
1
scenes/combat/action_resolver.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dyfn38jkq6ld8
|
||||||
42
scenes/combat/player_projectile.gd
Normal file
42
scenes/combat/player_projectile.gd
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
class_name PlayerProjectile
|
||||||
|
extends Node2D
|
||||||
|
|
||||||
|
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
|
||||||
|
const FRAME_COUNT := 4
|
||||||
|
const FRAME_TIME := 0.06
|
||||||
|
const LIFE_TIME := 1.2
|
||||||
|
|
||||||
|
var direction := Vector2.RIGHT
|
||||||
|
var speed := 360.0
|
||||||
|
var _age := 0.0
|
||||||
|
var _sprite: Sprite2D
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
_create_sprite()
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("player_projectiles")
|
||||||
|
if _sprite == null:
|
||||||
|
_create_sprite()
|
||||||
|
|
||||||
|
|
||||||
|
func _create_sprite() -> void:
|
||||||
|
_sprite = Sprite2D.new()
|
||||||
|
_sprite.texture = EFFECT_TEXTURE
|
||||||
|
_sprite.hframes = 6
|
||||||
|
_sprite.vframes = 2
|
||||||
|
_sprite.centered = true
|
||||||
|
_sprite.scale = Vector2(2.0, 2.0)
|
||||||
|
add_child(_sprite)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_age += delta
|
||||||
|
position += direction.normalized() * speed * delta
|
||||||
|
_sprite.frame = int(_age / FRAME_TIME) % FRAME_COUNT
|
||||||
|
if direction.x < 0.0:
|
||||||
|
_sprite.flip_h = true
|
||||||
|
if _age >= LIFE_TIME:
|
||||||
|
queue_free()
|
||||||
1
scenes/combat/player_projectile.gd.uid
Normal file
1
scenes/combat/player_projectile.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bwjk27wxb6p20
|
||||||
6
scenes/combat/player_projectile.tscn
Normal file
6
scenes/combat/player_projectile.tscn
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scenes/combat/player_projectile.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="PlayerProjectile" type="Node2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
343
scenes/components/action_controller.gd
Normal file
343
scenes/components/action_controller.gd
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
class_name ActionController
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal action_started(action: Resource, intent)
|
||||||
|
signal action_active_started(action: Resource, intent)
|
||||||
|
signal action_active_finished(action: Resource)
|
||||||
|
signal action_finished(action: Resource)
|
||||||
|
signal action_rejected(intent, reason: StringName)
|
||||||
|
|
||||||
|
enum Phase { IDLE, STARTUP, ACTIVE, RECOVERY }
|
||||||
|
|
||||||
|
@export var combo_window_path: NodePath
|
||||||
|
@export var action_resolver_path: NodePath
|
||||||
|
@export var action_executor_path: NodePath
|
||||||
|
@export var state_machine_path: NodePath
|
||||||
|
@export var burst_component_path: NodePath
|
||||||
|
|
||||||
|
@onready var combo_window: Node = get_node_or_null(combo_window_path)
|
||||||
|
@onready var action_resolver: Node = get_node_or_null(action_resolver_path)
|
||||||
|
@onready var action_executor: Node = get_node_or_null(action_executor_path)
|
||||||
|
@onready var state_machine: Node = get_node_or_null(state_machine_path)
|
||||||
|
@onready var burst_component: Node = get_node_or_null(burst_component_path)
|
||||||
|
|
||||||
|
var phase := Phase.IDLE
|
||||||
|
var current_action: Resource
|
||||||
|
var current_intent
|
||||||
|
var pending_intent
|
||||||
|
var phase_elapsed := 0.0
|
||||||
|
var phase_duration := 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func submit_intent(intent) -> void:
|
||||||
|
if intent == null:
|
||||||
|
return
|
||||||
|
var judged_intent = _ensure_judged(intent)
|
||||||
|
if judged_intent.is_released():
|
||||||
|
action_rejected.emit(judged_intent, &"release_not_action")
|
||||||
|
return
|
||||||
|
_emit_judgement_feedback(judged_intent)
|
||||||
|
if _judgement_label(judged_intent) == &"miss":
|
||||||
|
_interrupt_current_action_for_miss()
|
||||||
|
_record_miss(judged_intent)
|
||||||
|
action_rejected.emit(judged_intent, &"miss")
|
||||||
|
return
|
||||||
|
if _window_is_showing_pending_clear():
|
||||||
|
_store_pending_intent(judged_intent)
|
||||||
|
return
|
||||||
|
if phase == Phase.IDLE:
|
||||||
|
_consume_intent(judged_intent)
|
||||||
|
return
|
||||||
|
if _can_cancel_now():
|
||||||
|
_reset_to_idle()
|
||||||
|
_consume_intent(judged_intent)
|
||||||
|
return
|
||||||
|
_store_pending_intent(judged_intent)
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
if phase == Phase.IDLE:
|
||||||
|
if pending_intent != null and not _window_is_showing_pending_clear():
|
||||||
|
var idle_intent = pending_intent
|
||||||
|
pending_intent = null
|
||||||
|
_consume_intent(idle_intent)
|
||||||
|
return
|
||||||
|
phase_elapsed += delta
|
||||||
|
if phase == Phase.RECOVERY and pending_intent != null and _can_cancel_now():
|
||||||
|
var interrupted_action := current_action
|
||||||
|
var next_intent = pending_intent
|
||||||
|
pending_intent = null
|
||||||
|
_reset_to_idle()
|
||||||
|
action_finished.emit(interrupted_action)
|
||||||
|
_consume_intent(next_intent)
|
||||||
|
return
|
||||||
|
if phase_elapsed < phase_duration:
|
||||||
|
return
|
||||||
|
var carryover := maxf(0.0, phase_elapsed - phase_duration)
|
||||||
|
match phase:
|
||||||
|
Phase.STARTUP:
|
||||||
|
_enter_phase(Phase.ACTIVE)
|
||||||
|
phase_elapsed = carryover
|
||||||
|
if not _activate_current_action():
|
||||||
|
return
|
||||||
|
Phase.ACTIVE:
|
||||||
|
action_active_finished.emit(current_action)
|
||||||
|
_enter_phase(Phase.RECOVERY)
|
||||||
|
phase_elapsed = carryover
|
||||||
|
Phase.RECOVERY:
|
||||||
|
var finished_action := current_action
|
||||||
|
var next_intent = pending_intent
|
||||||
|
pending_intent = null
|
||||||
|
_reset_to_idle()
|
||||||
|
_clear_window_after_action(finished_action)
|
||||||
|
action_finished.emit(finished_action)
|
||||||
|
if next_intent != null:
|
||||||
|
_consume_intent(next_intent)
|
||||||
|
|
||||||
|
|
||||||
|
func _consume_intent(intent) -> void:
|
||||||
|
_start_action(intent)
|
||||||
|
|
||||||
|
|
||||||
|
func _start_action(intent) -> void:
|
||||||
|
if combo_window == null or action_resolver == null:
|
||||||
|
action_rejected.emit(intent, &"missing_component")
|
||||||
|
return
|
||||||
|
if _should_restart_blade_chain(intent):
|
||||||
|
combo_window.clear(&"blade_chain_restart")
|
||||||
|
_record_intent_symbol(intent)
|
||||||
|
var action: Resource = action_resolver.resolve_window(combo_window, state_machine, _resolver_context())
|
||||||
|
if action == null:
|
||||||
|
action = _resolve_fallback_action(intent)
|
||||||
|
if action == null:
|
||||||
|
action_rejected.emit(intent, &"unresolved")
|
||||||
|
return
|
||||||
|
current_action = action
|
||||||
|
current_intent = intent
|
||||||
|
_enter_phase(Phase.STARTUP)
|
||||||
|
action_started.emit(action, intent)
|
||||||
|
|
||||||
|
|
||||||
|
func _activate_current_action() -> bool:
|
||||||
|
if current_action == null or current_intent == null:
|
||||||
|
_reset_to_idle()
|
||||||
|
return false
|
||||||
|
if action_executor == null:
|
||||||
|
action_rejected.emit(current_intent, &"missing_component")
|
||||||
|
_reset_to_idle()
|
||||||
|
return false
|
||||||
|
if not action_executor.execute(current_action, StringName(str(current_intent.judgement.get("label", "perfect"))), burst_component):
|
||||||
|
combo_window.flush_pending_clear()
|
||||||
|
combo_window.clear(&"action_failed")
|
||||||
|
action_rejected.emit(current_intent, &"execution_failed")
|
||||||
|
_reset_to_idle()
|
||||||
|
return false
|
||||||
|
action_active_started.emit(current_action, current_intent)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _record_intent_symbol(intent) -> void:
|
||||||
|
if combo_window.has_pending_clear():
|
||||||
|
combo_window.flush_pending_clear()
|
||||||
|
combo_window.record(intent.symbol)
|
||||||
|
|
||||||
|
|
||||||
|
func _record_miss(_intent) -> void:
|
||||||
|
if combo_window != null:
|
||||||
|
if combo_window.has_pending_clear():
|
||||||
|
combo_window.flush_pending_clear()
|
||||||
|
combo_window.record(&"Ø")
|
||||||
|
|
||||||
|
|
||||||
|
func _interrupt_current_action_for_miss() -> void:
|
||||||
|
pending_intent = null
|
||||||
|
if phase == Phase.IDLE:
|
||||||
|
return
|
||||||
|
var interrupted_action := current_action
|
||||||
|
_reset_to_idle()
|
||||||
|
if interrupted_action != null:
|
||||||
|
action_finished.emit(interrupted_action)
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_fallback_action(intent) -> Resource:
|
||||||
|
if action_resolver == null:
|
||||||
|
return null
|
||||||
|
if not action_resolver.has_method("resolve_text_pattern"):
|
||||||
|
return null
|
||||||
|
return action_resolver.call("resolve_text_pattern", str(intent.symbol), state_machine, _resolver_context()) as Resource
|
||||||
|
|
||||||
|
|
||||||
|
func _clear_window_after_action(action: Resource) -> void:
|
||||||
|
if combo_window == null or action == null:
|
||||||
|
return
|
||||||
|
if bool(action.get("clear_window")):
|
||||||
|
combo_window.clear(StringName("skill:%s" % action.get("id")))
|
||||||
|
|
||||||
|
|
||||||
|
func _store_pending_intent(intent) -> void:
|
||||||
|
if pending_intent != null:
|
||||||
|
action_rejected.emit(pending_intent, &"replaced")
|
||||||
|
pending_intent = intent
|
||||||
|
|
||||||
|
|
||||||
|
func _should_restart_blade_chain(intent) -> bool:
|
||||||
|
if combo_window == null:
|
||||||
|
return false
|
||||||
|
if intent.symbol != &"S":
|
||||||
|
return false
|
||||||
|
var pattern: String = combo_window.get_contiguous_pattern()
|
||||||
|
return pattern == "SSP" or pattern == "SSPSP" or pattern == "SSPSPSP"
|
||||||
|
|
||||||
|
|
||||||
|
func _enter_phase(next_phase: Phase) -> void:
|
||||||
|
phase = next_phase
|
||||||
|
phase_elapsed = 0.0
|
||||||
|
phase_duration = _phase_duration_seconds(next_phase)
|
||||||
|
|
||||||
|
|
||||||
|
func _phase_duration_seconds(next_phase: Phase) -> float:
|
||||||
|
if current_action == null:
|
||||||
|
return 0.0
|
||||||
|
var beat_time := _beat_time()
|
||||||
|
match next_phase:
|
||||||
|
Phase.STARTUP:
|
||||||
|
return maxf(0.01, float(current_action.get("startup_beats")) * beat_time)
|
||||||
|
Phase.ACTIVE:
|
||||||
|
return maxf(0.01, float(current_action.get("active_beats")) * beat_time)
|
||||||
|
Phase.RECOVERY:
|
||||||
|
return maxf(0.01, float(current_action.get("recovery_beats")) * beat_time)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _can_cancel_now() -> bool:
|
||||||
|
if phase != Phase.RECOVERY or current_action == null:
|
||||||
|
return false
|
||||||
|
var duration := maxf(0.01, phase_duration)
|
||||||
|
var progress := clampf(phase_elapsed / duration, 0.0, 1.0)
|
||||||
|
return progress >= clampf(float(current_action.get("cancel_from")), 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func _reset_to_idle() -> void:
|
||||||
|
phase = Phase.IDLE
|
||||||
|
current_action = null
|
||||||
|
current_intent = null
|
||||||
|
phase_elapsed = 0.0
|
||||||
|
phase_duration = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _window_is_showing_pending_clear() -> bool:
|
||||||
|
return combo_window != null and combo_window.has_pending_clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _ensure_judged(intent):
|
||||||
|
if not intent.judgement.is_empty():
|
||||||
|
return intent.with_judgement(_judgement_with_defaults(intent.judgement))
|
||||||
|
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||||
|
if rhythm != null and rhythm.has_method("judge"):
|
||||||
|
return intent.with_judgement(_judgement_with_defaults(rhythm.call("judge", intent.timestamp_ms)))
|
||||||
|
return intent.with_judgement(_judgement_with_defaults({"label": "perfect", "diff": 0.0, "abs_diff": 0.0}))
|
||||||
|
|
||||||
|
|
||||||
|
func _judgement_label(intent) -> StringName:
|
||||||
|
return StringName(str(intent.judgement.get("label", "miss")))
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_judgement_feedback(intent) -> void:
|
||||||
|
var rating := _judgement_with_defaults(intent.judgement)
|
||||||
|
var action_name: StringName = intent.rhythm_action if not intent.rhythm_action.is_empty() else intent.symbol
|
||||||
|
rating["action"] = action_name
|
||||||
|
var label := StringName(str(rating.get("label", "miss")))
|
||||||
|
var diff_ms := float(rating.get("diff", INF)) * 1000.0
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus == null:
|
||||||
|
return
|
||||||
|
bus.emit_signal("judgement_made", label, diff_ms)
|
||||||
|
bus.emit_signal("action_judged", action_name, rating)
|
||||||
|
|
||||||
|
|
||||||
|
func _judgement_with_defaults(judgement: Dictionary) -> Dictionary:
|
||||||
|
var rating := judgement.duplicate()
|
||||||
|
var label := StringName(str(rating.get("label", "miss")))
|
||||||
|
rating["label"] = str(label)
|
||||||
|
if not rating.has("diff"):
|
||||||
|
rating["diff"] = 0.0
|
||||||
|
if not rating.has("abs_diff"):
|
||||||
|
rating["abs_diff"] = absf(float(rating.get("diff", 0.0)))
|
||||||
|
if not rating.has("color"):
|
||||||
|
rating["color"] = _judgement_color(label)
|
||||||
|
return rating
|
||||||
|
|
||||||
|
|
||||||
|
func _judgement_color(label: StringName) -> Color:
|
||||||
|
match label:
|
||||||
|
&"perfect":
|
||||||
|
return Color("00f2ff")
|
||||||
|
&"good":
|
||||||
|
return Color("ffffff")
|
||||||
|
&"bad":
|
||||||
|
return Color("ffaa00")
|
||||||
|
return Color("ff0055")
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
func _resolver_context() -> Dictionary:
|
||||||
|
var state := &"any"
|
||||||
|
if state_machine != null and state_machine.has_method("get_current_state_name"):
|
||||||
|
state = StringName(str(state_machine.call("get_current_state_name")))
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"charge_release_action_id": _charge_release_action_id(),
|
||||||
|
"burst_action_id": _burst_action_id(),
|
||||||
|
"counter_action_id": _counter_action_id(),
|
||||||
|
"counter_ready": _counter_ready(),
|
||||||
|
"blade_chain_action_id": _blade_chain_action_id(),
|
||||||
|
"blade_chain_active": _blade_chain_active(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _charge_release_action_id() -> StringName:
|
||||||
|
return &""
|
||||||
|
|
||||||
|
|
||||||
|
func _burst_action_id() -> StringName:
|
||||||
|
if burst_component != null and bool(burst_component.get("burst_ready")):
|
||||||
|
return &"skill_burst_activate"
|
||||||
|
return &""
|
||||||
|
|
||||||
|
|
||||||
|
func _counter_action_id() -> StringName:
|
||||||
|
return &"skill_s_counter_projectile"
|
||||||
|
|
||||||
|
|
||||||
|
func _counter_ready() -> bool:
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _blade_chain_action_id() -> StringName:
|
||||||
|
if _blade_chain_active():
|
||||||
|
return &"skill_s_projectile_2"
|
||||||
|
return &""
|
||||||
|
|
||||||
|
|
||||||
|
func _blade_chain_active() -> bool:
|
||||||
|
if current_action == null:
|
||||||
|
return false
|
||||||
|
return bool(current_action.get("can_chain"))
|
||||||
|
|
||||||
|
|
||||||
|
func _beat_time() -> float:
|
||||||
|
var rhythm := get_tree().root.get_node_or_null("RhythmManager") if is_inside_tree() else null
|
||||||
|
if rhythm != null:
|
||||||
|
return float(rhythm.get("beat_time"))
|
||||||
|
return 0.5
|
||||||
1
scenes/components/action_controller.gd.uid
Normal file
1
scenes/components/action_controller.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bk4dutttdieeg
|
||||||
42
scenes/components/action_executor.gd
Normal file
42
scenes/components/action_executor.gd
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
class_name ActionExecutor
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal action_executed(action: Resource, judgement: StringName)
|
||||||
|
signal action_failed(action: Resource, reason: StringName)
|
||||||
|
|
||||||
|
@export var energy_component_path: NodePath
|
||||||
|
@export var damage_emitter_path: NodePath
|
||||||
|
|
||||||
|
@onready var _energy_component: Node = get_node_or_null(energy_component_path)
|
||||||
|
@onready var _damage_emitter: Node = get_node_or_null(damage_emitter_path)
|
||||||
|
|
||||||
|
|
||||||
|
func execute(action: Resource, judgement: StringName, burst_component: Variant = null) -> bool:
|
||||||
|
if action == null:
|
||||||
|
action_failed.emit(action, &"missing_action")
|
||||||
|
return false
|
||||||
|
var cost := _resolve_cost(action, burst_component)
|
||||||
|
if _energy_component != null and not _energy_component.spend(cost):
|
||||||
|
action_failed.emit(action, &"insufficient_energy")
|
||||||
|
return false
|
||||||
|
if _damage_emitter != null and _damage_emitter.has_method("configure_hit"):
|
||||||
|
_damage_emitter.configure_hit(action, {"label": str(judgement)})
|
||||||
|
var reward := int(action.get("energy_reward"))
|
||||||
|
if reward != 0 and _energy_component != null:
|
||||||
|
_energy_component.change(reward)
|
||||||
|
action_executed.emit(action, judgement)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_cost(action: Resource, burst_component: Variant) -> float:
|
||||||
|
var combat := _combat_manager_or_null()
|
||||||
|
if combat != null:
|
||||||
|
return float(combat.call("resolve_cost", action, burst_component))
|
||||||
|
var base_cost := float(action.get("base_cost"))
|
||||||
|
return base_cost if base_cost > 0.0 else float(action.get("energy_cost"))
|
||||||
|
|
||||||
|
|
||||||
|
func _combat_manager_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("CombatManager")
|
||||||
1
scenes/components/action_executor.gd.uid
Normal file
1
scenes/components/action_executor.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cxcw3mad0gewc
|
||||||
68
scenes/components/burst_component.gd
Normal file
68
scenes/components/burst_component.gd
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
class_name BurstComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal burst_changed(burst_ready: bool, active: bool, cooldown: int)
|
||||||
|
|
||||||
|
@export var active_beats := 16
|
||||||
|
@export var cooldown_beats := 4
|
||||||
|
|
||||||
|
var burst_ready := false
|
||||||
|
var active := false
|
||||||
|
var cooldown := 0
|
||||||
|
var _beats_left := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
|
||||||
|
if rhythm != null and not rhythm.is_connected("beat_ticked", _on_beat_ticked):
|
||||||
|
rhythm.connect("beat_ticked", _on_beat_ticked)
|
||||||
|
|
||||||
|
|
||||||
|
func set_ready(value: bool) -> void:
|
||||||
|
if active or cooldown > 0:
|
||||||
|
burst_ready = false
|
||||||
|
else:
|
||||||
|
burst_ready = value
|
||||||
|
burst_changed.emit(burst_ready, active, cooldown)
|
||||||
|
|
||||||
|
|
||||||
|
func activate() -> bool:
|
||||||
|
if not burst_ready or active or cooldown > 0:
|
||||||
|
return false
|
||||||
|
burst_ready = false
|
||||||
|
active = true
|
||||||
|
_beats_left = active_beats
|
||||||
|
_set_rhythm_scale(1.25)
|
||||||
|
burst_changed.emit(burst_ready, active, cooldown)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func damage_mult(_action: Resource = null) -> float:
|
||||||
|
return 1.2 if active else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func cost_mult(_action: Resource = null) -> float:
|
||||||
|
return 0.0 if active else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func move_mult(_action: Resource = null) -> float:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
|
func _on_beat_ticked(_beat_index: int) -> void:
|
||||||
|
if active:
|
||||||
|
_beats_left -= 1
|
||||||
|
if _beats_left <= 0:
|
||||||
|
active = false
|
||||||
|
cooldown = cooldown_beats
|
||||||
|
_set_rhythm_scale(1.0)
|
||||||
|
burst_changed.emit(burst_ready, active, cooldown)
|
||||||
|
elif cooldown > 0:
|
||||||
|
cooldown -= 1
|
||||||
|
burst_changed.emit(burst_ready, active, cooldown)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_rhythm_scale(scale: float) -> void:
|
||||||
|
var rhythm := get_tree().root.get_node_or_null("RhythmManager")
|
||||||
|
if rhythm != null:
|
||||||
|
rhythm.set("judgement_scale", scale)
|
||||||
1
scenes/components/burst_component.gd.uid
Normal file
1
scenes/components/burst_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://vsrv3pov77hh
|
||||||
126
scenes/components/charge_component.gd
Normal file
126
scenes/components/charge_component.gd
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
class_name ChargeComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal charge_changed(current: float, maximum: float, charge_ready: bool, active: bool)
|
||||||
|
signal release_requested(skill_id: StringName, direction: Vector2)
|
||||||
|
|
||||||
|
@export var charge_duration := 1.1
|
||||||
|
@export var animation_player_path: NodePath
|
||||||
|
@export var effect_sprite_path: NodePath
|
||||||
|
|
||||||
|
var value := 0.0
|
||||||
|
var charge_ready := false
|
||||||
|
var active := false
|
||||||
|
|
||||||
|
var _hold_symbol: StringName = &""
|
||||||
|
var _hold_direction := Vector2.ZERO
|
||||||
|
var _effect_time := 0.0
|
||||||
|
var _animation_time := 0.0
|
||||||
|
|
||||||
|
@onready var _animation_player: AnimationPlayer = get_node_or_null(animation_player_path) as AnimationPlayer
|
||||||
|
@onready var _effect_sprite: Sprite2D = get_node_or_null(effect_sprite_path) as Sprite2D
|
||||||
|
|
||||||
|
|
||||||
|
func begin_hold(symbol: StringName, direction: Vector2) -> void:
|
||||||
|
_hold_symbol = symbol
|
||||||
|
_hold_direction = direction
|
||||||
|
|
||||||
|
|
||||||
|
func finish_hold(symbol: StringName) -> void:
|
||||||
|
if _hold_symbol != symbol:
|
||||||
|
return
|
||||||
|
var release_ready := active and charge_ready
|
||||||
|
var release_direction := _hold_direction
|
||||||
|
var release_skill := &"skill_a_charge_release" if symbol == &"A" else &"skill_d_charge_release"
|
||||||
|
cancel()
|
||||||
|
if release_ready:
|
||||||
|
release_requested.emit(release_skill, release_direction)
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float, can_charge: bool) -> void:
|
||||||
|
if _hold_symbol.is_empty():
|
||||||
|
return
|
||||||
|
if not active:
|
||||||
|
if not can_charge:
|
||||||
|
return
|
||||||
|
_start()
|
||||||
|
if not active:
|
||||||
|
return
|
||||||
|
_update_charge_animation(delta)
|
||||||
|
value = minf(charge_duration, value + delta)
|
||||||
|
charge_ready = value >= charge_duration
|
||||||
|
_update_charge_effect(delta)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel() -> void:
|
||||||
|
_hold_symbol = &""
|
||||||
|
_hold_direction = Vector2.ZERO
|
||||||
|
active = false
|
||||||
|
value = 0.0
|
||||||
|
charge_ready = false
|
||||||
|
_animation_time = 0.0
|
||||||
|
_set_effect_visible(false)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func is_active() -> bool:
|
||||||
|
return active
|
||||||
|
|
||||||
|
|
||||||
|
func is_ready() -> bool:
|
||||||
|
return charge_ready
|
||||||
|
|
||||||
|
|
||||||
|
func maximum() -> float:
|
||||||
|
return charge_duration
|
||||||
|
|
||||||
|
|
||||||
|
func _start() -> void:
|
||||||
|
active = true
|
||||||
|
value = 0.0
|
||||||
|
charge_ready = false
|
||||||
|
_effect_time = 0.0
|
||||||
|
_animation_time = 0.0
|
||||||
|
_play_charge_animation(&"warrior_charge_intro")
|
||||||
|
_update_charge_effect(0.0)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_charge_effect(delta: float) -> void:
|
||||||
|
if _effect_sprite == null:
|
||||||
|
return
|
||||||
|
_effect_sprite.visible = active
|
||||||
|
if not active:
|
||||||
|
return
|
||||||
|
_effect_time += delta
|
||||||
|
_effect_sprite.frame = int(_effect_time * 12.0) % 5
|
||||||
|
|
||||||
|
|
||||||
|
func _update_charge_animation(delta: float) -> void:
|
||||||
|
_animation_time += delta
|
||||||
|
var intro_length := _animation_length(&"warrior_charge_intro")
|
||||||
|
if _animation_time < intro_length:
|
||||||
|
_play_charge_animation(&"warrior_charge_intro")
|
||||||
|
else:
|
||||||
|
_play_charge_animation(&"warrior_charge_loop")
|
||||||
|
|
||||||
|
|
||||||
|
func _play_charge_animation(animation_name: StringName) -> void:
|
||||||
|
if _animation_player != null and _animation_player.has_animation(animation_name) and _animation_player.current_animation != animation_name:
|
||||||
|
_animation_player.play(animation_name)
|
||||||
|
|
||||||
|
|
||||||
|
func _animation_length(animation_name: StringName) -> float:
|
||||||
|
if _animation_player != null and _animation_player.has_animation(animation_name):
|
||||||
|
return maxf(0.1, _animation_player.get_animation(animation_name).length)
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
|
||||||
|
func _set_effect_visible(is_visible: bool) -> void:
|
||||||
|
if _effect_sprite != null:
|
||||||
|
_effect_sprite.visible = is_visible
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_changed() -> void:
|
||||||
|
charge_changed.emit(value, charge_duration, charge_ready, active)
|
||||||
1
scenes/components/charge_component.gd.uid
Normal file
1
scenes/components/charge_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://417rdyr4hkco
|
||||||
108
scenes/components/combo_window.gd
Normal file
108
scenes/components/combo_window.gd
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
class_name ComboWindow
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal combo_updated(inputs: Array[StringName])
|
||||||
|
signal combo_cleared(reason: StringName)
|
||||||
|
|
||||||
|
@export var size := 4
|
||||||
|
@export var clear_display_time := 0.35
|
||||||
|
|
||||||
|
var slots: Array[StringName] = []
|
||||||
|
var pending_clear_reason: StringName = &""
|
||||||
|
var _timer: Timer
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_timer = Timer.new()
|
||||||
|
_timer.one_shot = true
|
||||||
|
_timer.timeout.connect(flush_pending_clear)
|
||||||
|
add_child(_timer)
|
||||||
|
|
||||||
|
|
||||||
|
func record(input: StringName) -> void:
|
||||||
|
if input.is_empty():
|
||||||
|
return
|
||||||
|
slots.append(input)
|
||||||
|
combo_updated.emit(get_slots())
|
||||||
|
_emit_bus_signal("combo_updated", [get_slots()])
|
||||||
|
if slots.size() >= size:
|
||||||
|
queue_clear(&"full")
|
||||||
|
|
||||||
|
|
||||||
|
func get_slots() -> Array[StringName]:
|
||||||
|
return slots.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
func has_pending_clear() -> bool:
|
||||||
|
return not pending_clear_reason.is_empty()
|
||||||
|
|
||||||
|
|
||||||
|
func consume_pending_clear_reason() -> StringName:
|
||||||
|
var reason := pending_clear_reason
|
||||||
|
pending_clear_reason = &""
|
||||||
|
return reason
|
||||||
|
|
||||||
|
|
||||||
|
func get_pattern() -> String:
|
||||||
|
var pattern := ""
|
||||||
|
for slot: StringName in slots:
|
||||||
|
if slot != &"Ø":
|
||||||
|
pattern += str(slot)
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
func get_contiguous_pattern() -> String:
|
||||||
|
var pattern := ""
|
||||||
|
for index: int in range(slots.size() - 1, -1, -1):
|
||||||
|
var slot := slots[index]
|
||||||
|
if slot == &"Ø":
|
||||||
|
break
|
||||||
|
pattern = str(slot) + pattern
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
func queue_clear(reason: StringName, delay := -1.0) -> void:
|
||||||
|
pending_clear_reason = reason
|
||||||
|
if _timer == null:
|
||||||
|
return
|
||||||
|
_timer.stop()
|
||||||
|
_timer.wait_time = clear_display_time if delay < 0.0 else delay
|
||||||
|
_timer.start()
|
||||||
|
|
||||||
|
|
||||||
|
func cancel_pending_clear() -> void:
|
||||||
|
pending_clear_reason = &""
|
||||||
|
if _timer != null:
|
||||||
|
_timer.stop()
|
||||||
|
|
||||||
|
|
||||||
|
func flush_pending_clear() -> void:
|
||||||
|
var reason := consume_pending_clear_reason()
|
||||||
|
if reason.is_empty():
|
||||||
|
return
|
||||||
|
if _timer != null:
|
||||||
|
_timer.stop()
|
||||||
|
clear(reason)
|
||||||
|
|
||||||
|
|
||||||
|
func clear(reason: StringName = &"") -> void:
|
||||||
|
slots.clear()
|
||||||
|
pending_clear_reason = &""
|
||||||
|
combo_cleared.emit(reason)
|
||||||
|
_emit_bus_signal("combo_cleared", [reason])
|
||||||
|
combo_updated.emit(get_slots())
|
||||||
|
_emit_bus_signal("combo_updated", [get_slots()])
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_bus_signal(signal_name: StringName, args: Array) -> void:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return
|
||||||
|
var bus := _event_bus_or_null()
|
||||||
|
if bus != null:
|
||||||
|
bus.emit_signal(signal_name, args[0])
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("EventBus")
|
||||||
1
scenes/components/combo_window.gd.uid
Normal file
1
scenes/components/combo_window.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jgl00xkxwy2s
|
||||||
47
scenes/components/damage_emitter.gd
Normal file
47
scenes/components/damage_emitter.gd
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
class_name DamageEmitter
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
@export var damage := 10
|
||||||
|
@export var hit_type: StringName = &"normal"
|
||||||
|
|
||||||
|
var action_context: Resource
|
||||||
|
var judgement_context: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
area_entered.connect(_on_area_entered)
|
||||||
|
|
||||||
|
|
||||||
|
func configure_hit(action: Resource, judgement: Dictionary) -> void:
|
||||||
|
action_context = action
|
||||||
|
judgement_context = judgement.duplicate()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_area_entered(receiver: Area2D) -> void:
|
||||||
|
if receiver.is_in_group("damage_receivers"):
|
||||||
|
var final_damage := _resolve_damage()
|
||||||
|
receiver.emit_signal("damage_received", final_damage, hit_type, global_position)
|
||||||
|
_event_bus().emit_signal("damage_dealt", receiver, final_damage, hit_type)
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_damage() -> int:
|
||||||
|
var combat := _combat_manager_or_null()
|
||||||
|
if combat == null:
|
||||||
|
return damage
|
||||||
|
return int(round(combat.call("resolve_damage", float(damage), action_context, judgement_context, null, null)))
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus() -> Node:
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
|
|
||||||
|
|
||||||
|
func _combat_manager_or_null() -> Node:
|
||||||
|
if not is_inside_tree():
|
||||||
|
return null
|
||||||
|
return get_tree().root.get_node_or_null("CombatManager")
|
||||||
1
scenes/components/damage_emitter.gd.uid
Normal file
1
scenes/components/damage_emitter.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://sw8ppylne36n
|
||||||
23
scenes/components/damage_receiver.gd
Normal file
23
scenes/components/damage_receiver.gd
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class_name DamageReceiver
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
signal damage_received(amount: int, hit_type: StringName, from: Vector2)
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("damage_receivers")
|
||||||
|
|
||||||
|
|
||||||
|
func take_damage(amount: int, hit_type: StringName, from: Vector2) -> void:
|
||||||
|
damage_received.emit(amount, hit_type, from)
|
||||||
|
_event_bus().emit_signal("damage_dealt", self, amount, hit_type)
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus() -> Node:
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
1
scenes/components/damage_receiver.gd.uid
Normal file
1
scenes/components/damage_receiver.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b1ljynf0b873n
|
||||||
54
scenes/components/energy_component.gd
Normal file
54
scenes/components/energy_component.gd
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
class_name EnergyComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal energy_changed(current: int, maximum: int)
|
||||||
|
|
||||||
|
@export var maximum := 10
|
||||||
|
@export var current := 0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func set_values(next_current: int, next_maximum: int) -> void:
|
||||||
|
maximum = max(1, next_maximum)
|
||||||
|
current = clampi(next_current, 0, maximum)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func set_current(next_current: int) -> void:
|
||||||
|
var clamped := clampi(next_current, 0, maximum)
|
||||||
|
if clamped == current:
|
||||||
|
return
|
||||||
|
current = clamped
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func change(delta: int) -> void:
|
||||||
|
set_current(current + delta)
|
||||||
|
|
||||||
|
|
||||||
|
func spend(cost: float) -> bool:
|
||||||
|
var int_cost := int(ceil(cost))
|
||||||
|
if int_cost <= 0:
|
||||||
|
return true
|
||||||
|
if current < int_cost:
|
||||||
|
return false
|
||||||
|
set_current(current - int_cost)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_changed() -> void:
|
||||||
|
energy_changed.emit(current, maximum)
|
||||||
|
_event_bus().emit_signal("player_energy_changed", float(current), float(maximum))
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus() -> Node:
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
1
scenes/components/energy_component.gd.uid
Normal file
1
scenes/components/energy_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bwau6ud30k62u
|
||||||
49
scenes/components/health_component.gd
Normal file
49
scenes/components/health_component.gd
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
class_name HealthComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal health_changed(current: int, maximum: int)
|
||||||
|
signal depleted
|
||||||
|
|
||||||
|
@export var maximum := 100
|
||||||
|
@export var current := 100
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func set_values(next_current: int, next_maximum: int) -> void:
|
||||||
|
maximum = max(1, next_maximum)
|
||||||
|
current = clampi(next_current, 0, maximum)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_damage(amount: int) -> void:
|
||||||
|
if amount <= 0:
|
||||||
|
return
|
||||||
|
current = clampi(current - amount, 0, maximum)
|
||||||
|
_emit_changed()
|
||||||
|
if current == 0:
|
||||||
|
depleted.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func heal(amount: int) -> void:
|
||||||
|
if amount <= 0:
|
||||||
|
return
|
||||||
|
current = clampi(current + amount, 0, maximum)
|
||||||
|
_emit_changed()
|
||||||
|
|
||||||
|
|
||||||
|
func _emit_changed() -> void:
|
||||||
|
health_changed.emit(current, maximum)
|
||||||
|
_event_bus().emit_signal("player_health_changed", current, maximum)
|
||||||
|
|
||||||
|
|
||||||
|
func _event_bus() -> Node:
|
||||||
|
var root := get_tree().root
|
||||||
|
var bus := root.get_node_or_null("EventBus")
|
||||||
|
if bus == null:
|
||||||
|
bus = load("res://autoload/event_bus.gd").new()
|
||||||
|
bus.name = "EventBus"
|
||||||
|
root.add_child(bus)
|
||||||
|
return bus
|
||||||
1
scenes/components/health_component.gd.uid
Normal file
1
scenes/components/health_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://0svshg5yfjyg
|
||||||
65
scenes/components/input_component.gd
Normal file
65
scenes/components/input_component.gd
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
class_name InputComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
const InputIntentScript := preload("res://scenes/components/input_intent.gd")
|
||||||
|
|
||||||
|
signal intent_created(intent)
|
||||||
|
signal combo_pressed(symbol: StringName, rhythm_action: StringName)
|
||||||
|
signal combo_released(symbol: StringName)
|
||||||
|
|
||||||
|
const COMBO_ACTIONS: Dictionary = {
|
||||||
|
&"combo_w": [&"W", &"w"],
|
||||||
|
&"combo_a": [&"A", &"a"],
|
||||||
|
&"combo_d": [&"D", &"d"],
|
||||||
|
&"combo_s": [&"S", &"s"],
|
||||||
|
&"combo_space": [&"SP", &"space"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMBO_ACTION_ORDER: Array[StringName] = [
|
||||||
|
&"combo_w",
|
||||||
|
&"combo_a",
|
||||||
|
&"combo_d",
|
||||||
|
&"combo_s",
|
||||||
|
&"combo_space",
|
||||||
|
]
|
||||||
|
|
||||||
|
var _suppressed_movement: Dictionary = {
|
||||||
|
&"move_left": false,
|
||||||
|
&"move_right": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func handle_input_event(event: InputEvent) -> bool:
|
||||||
|
var key_event := event as InputEventKey
|
||||||
|
if key_event != null and key_event.echo:
|
||||||
|
return false
|
||||||
|
for action_name: StringName in COMBO_ACTION_ORDER:
|
||||||
|
if event.is_action_pressed(action_name, false, true):
|
||||||
|
var data: Array = COMBO_ACTIONS[action_name]
|
||||||
|
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"pressed", float(Time.get_ticks_msec()))
|
||||||
|
intent_created.emit(intent)
|
||||||
|
combo_pressed.emit(data[0], data[1])
|
||||||
|
return true
|
||||||
|
if event.is_action_released(action_name, true):
|
||||||
|
var data: Array = COMBO_ACTIONS[action_name]
|
||||||
|
var intent: RefCounted = InputIntentScript.create(data[0], data[1], &"released", float(Time.get_ticks_msec()))
|
||||||
|
intent_created.emit(intent)
|
||||||
|
combo_released.emit(data[0])
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func set_direction_suppressed(symbol: StringName, suppressed: bool) -> void:
|
||||||
|
if symbol == &"A":
|
||||||
|
_suppressed_movement[&"move_left"] = suppressed
|
||||||
|
elif symbol == &"D":
|
||||||
|
_suppressed_movement[&"move_right"] = suppressed
|
||||||
|
|
||||||
|
|
||||||
|
func get_horizontal_axis() -> float:
|
||||||
|
var axis := 0.0
|
||||||
|
if Input.is_action_pressed(&"move_left") and not bool(_suppressed_movement.get(&"move_left", false)):
|
||||||
|
axis -= 1.0
|
||||||
|
if Input.is_action_pressed(&"move_right") and not bool(_suppressed_movement.get(&"move_right", false)):
|
||||||
|
axis += 1.0
|
||||||
|
return axis
|
||||||
1
scenes/components/input_component.gd.uid
Normal file
1
scenes/components/input_component.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c4n1p3g64qiqj
|
||||||
32
scenes/components/input_intent.gd
Normal file
32
scenes/components/input_intent.gd
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
class_name InputIntent
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var symbol: StringName
|
||||||
|
var rhythm_action: StringName
|
||||||
|
var event_type: StringName
|
||||||
|
var timestamp_ms := 0.0
|
||||||
|
var judgement: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
static func create(next_symbol: StringName, next_rhythm_action: StringName, next_event_type: StringName, next_timestamp_ms: float) -> RefCounted:
|
||||||
|
var script: Script = load("res://scenes/components/input_intent.gd")
|
||||||
|
var intent: RefCounted = script.new()
|
||||||
|
intent.symbol = next_symbol
|
||||||
|
intent.rhythm_action = next_rhythm_action
|
||||||
|
intent.event_type = next_event_type
|
||||||
|
intent.timestamp_ms = next_timestamp_ms
|
||||||
|
return intent
|
||||||
|
|
||||||
|
|
||||||
|
func is_pressed() -> bool:
|
||||||
|
return event_type == &"pressed"
|
||||||
|
|
||||||
|
|
||||||
|
func is_released() -> bool:
|
||||||
|
return event_type == &"released"
|
||||||
|
|
||||||
|
|
||||||
|
func with_judgement(next_judgement: Dictionary) -> RefCounted:
|
||||||
|
var copy: RefCounted = load("res://scenes/components/input_intent.gd").create(symbol, rhythm_action, event_type, timestamp_ms)
|
||||||
|
copy.judgement = next_judgement.duplicate()
|
||||||
|
return copy
|
||||||
1
scenes/components/input_intent.gd.uid
Normal file
1
scenes/components/input_intent.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://yurugl8r6qre
|
||||||
40
scenes/components/motion_executor.gd
Normal file
40
scenes/components/motion_executor.gd
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
class_name MotionExecutor
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal motion_started(action: Resource)
|
||||||
|
signal motion_finished(action: Resource)
|
||||||
|
|
||||||
|
var current_action: Resource
|
||||||
|
var velocity := Vector2.ZERO
|
||||||
|
var duration := 0.0
|
||||||
|
var elapsed := 0.0
|
||||||
|
var active := false
|
||||||
|
|
||||||
|
|
||||||
|
func execute(action: Resource, direction: Vector2, beat_time: float, speed := 220.0) -> void:
|
||||||
|
current_action = action
|
||||||
|
duration = maxf(0.01, float(action.get("action_beats")) * maxf(0.01, beat_time))
|
||||||
|
elapsed = 0.0
|
||||||
|
active = true
|
||||||
|
var move_x := float(action.get("move_mult_x"))
|
||||||
|
if move_x == 0.0:
|
||||||
|
move_x = -1.0 if StringName(str(action.get("displacement"))) == &"left" else 1.0 if StringName(str(action.get("displacement"))) == &"right" else 0.0
|
||||||
|
velocity = Vector2(direction.x if direction.x != 0.0 else move_x, direction.y).normalized() * speed if move_x != 0.0 or direction != Vector2.ZERO else Vector2.ZERO
|
||||||
|
motion_started.emit(action)
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> Vector2:
|
||||||
|
if not active:
|
||||||
|
return Vector2.ZERO
|
||||||
|
elapsed += delta
|
||||||
|
if elapsed >= duration:
|
||||||
|
active = false
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
motion_finished.emit(current_action)
|
||||||
|
return velocity
|
||||||
|
|
||||||
|
|
||||||
|
func cancel() -> void:
|
||||||
|
active = false
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
current_action = null
|
||||||
1
scenes/components/motion_executor.gd.uid
Normal file
1
scenes/components/motion_executor.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cqr3o0h5abv3f
|
||||||
34
scenes/components/state_machine.gd
Normal file
34
scenes/components/state_machine.gd
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
class_name StateMachine
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
signal state_changed(previous: int, current: int)
|
||||||
|
|
||||||
|
var current_state := 0
|
||||||
|
var state_names: Array[StringName] = [
|
||||||
|
&"ground",
|
||||||
|
&"ground",
|
||||||
|
&"air",
|
||||||
|
&"ground",
|
||||||
|
&"ground",
|
||||||
|
&"air",
|
||||||
|
&"guarding",
|
||||||
|
&"charging",
|
||||||
|
&"bladeChain",
|
||||||
|
&"burstCharge",
|
||||||
|
&"bursting",
|
||||||
|
&"hitstun",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func change_state(next_state: int) -> void:
|
||||||
|
if next_state == current_state:
|
||||||
|
return
|
||||||
|
var previous := current_state
|
||||||
|
current_state = next_state
|
||||||
|
state_changed.emit(previous, current_state)
|
||||||
|
|
||||||
|
|
||||||
|
func get_current_state_name() -> StringName:
|
||||||
|
if current_state >= 0 and current_state < state_names.size():
|
||||||
|
return state_names[current_state]
|
||||||
|
return &"any"
|
||||||
1
scenes/components/state_machine.gd.uid
Normal file
1
scenes/components/state_machine.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bxquc8qy20e6l
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user