- Select scene (new main scene): roster cards from characters/* with portraits, dual cursors (P1 A/D+J, P2 arrows+O), picks via GameState - finisher flow: KO puts loser in DIZZY with a 5s flashing FINISH! window; winner lands QCF+K in range -> finisher move (Brutal Assassination / Hell Slammer B) with darkened stage, victim plays synced death anim; window expiry or any stray hit = normal collapse - new moves rendered for both fighters in all wardrobe states: dizzy (Stunned), finisher, death (Standing Death Backward) - CHAR_TUNE per-character move overrides in tune_fighters.py Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
315 lines
9.0 KiB
GDScript
315 lines
9.0 KiB
GDScript
extends Node2D
|
|
## Match root: owns two fighters, drives the fixed 60Hz tick, resolves
|
|
## hits, renders HUD. Scene tree is built in code so the .tscn stays
|
|
## trivial. P1: WASD + J/K. P2: arrows + O/P. F1 hitboxes, R reset.
|
|
|
|
const BoxOverlay := preload("res://engine/debug/box_overlay.gd")
|
|
|
|
var p1: Fighter
|
|
var p2: Fighter
|
|
var roster: Array = []
|
|
var overlay: Node2D
|
|
var hud_p1: ColorRect
|
|
var hud_p2: ColorRect
|
|
var hud_label_1: Label
|
|
var hud_label_2: Label
|
|
var msg_label: Label
|
|
var darken: ColorRect
|
|
var tick_count := 0
|
|
var match_over := false
|
|
var finish_window := -1 # >0: loser dizzy, finisher available
|
|
var finisher_running := false
|
|
|
|
# screenshot/demo mode for CI-style verification: godot --path . -- --shot out.png
|
|
var shot_path := ""
|
|
|
|
|
|
func _ready() -> void:
|
|
for arg in OS.get_cmdline_user_args():
|
|
if arg.begins_with("--shot="):
|
|
shot_path = arg.trim_prefix("--shot=")
|
|
_build_stage()
|
|
_load_fighters()
|
|
_build_hud()
|
|
if shot_path != "" and p2 != null:
|
|
p2.hp = 60 # demo: one special KOs -> exercises the finisher flow
|
|
overlay = BoxOverlay.new()
|
|
overlay.engine = self
|
|
overlay.visible = shot_path != ""
|
|
add_child(overlay)
|
|
|
|
|
|
func _build_stage() -> void:
|
|
var bg := ColorRect.new()
|
|
bg.color = Color("#181a20")
|
|
bg.size = Vector2(1280, 720)
|
|
add_child(bg)
|
|
var floor_rect := ColorRect.new()
|
|
floor_rect.color = Color("#2a2e3a")
|
|
floor_rect.position = Vector2(0, CFG.GROUND_Y)
|
|
floor_rect.size = Vector2(1280, 720 - CFG.GROUND_Y)
|
|
add_child(floor_rect)
|
|
# fatality mood lighting: sits above the stage, below the fighters
|
|
darken = ColorRect.new()
|
|
darken.color = Color(0, 0, 0, 0.55)
|
|
darken.size = Vector2(1280, 720)
|
|
darken.visible = false
|
|
add_child(darken)
|
|
|
|
|
|
func _load_fighters() -> void:
|
|
roster = CharacterData.scan_roster()
|
|
if roster.size() < 1:
|
|
push_error("No characters found under characters/")
|
|
return
|
|
# picks come from the select screen; CLI --p1=/--p2= override for testing
|
|
var want := {"p1": GameState.p1_pick, "p2": GameState.p2_pick}
|
|
for arg in OS.get_cmdline_user_args():
|
|
for k in want:
|
|
if arg.begins_with("--%s=" % k):
|
|
want[k] = arg.trim_prefix("--%s=" % k)
|
|
var path1: String = roster[0]
|
|
var path2: String = roster[mini(1, roster.size() - 1)]
|
|
for r in roster:
|
|
if want.p1 != "" and r.get_file() == want.p1:
|
|
path1 = r
|
|
if want.p2 != "" and r.get_file() == want.p2:
|
|
path2 = r
|
|
var d1 := CharacterData.load_dir(path1)
|
|
var d2 := CharacterData.load_dir(path2)
|
|
# fighters live in stage space; the stage node centres it on screen
|
|
var stage := Node2D.new()
|
|
stage.position = Vector2(640, 0)
|
|
add_child(stage)
|
|
p1 = Fighter.new()
|
|
p2 = Fighter.new()
|
|
stage.add_child(p1)
|
|
stage.add_child(p2)
|
|
p1.setup(d1, -180.0, 1)
|
|
p2.setup(d2, 180.0, -1)
|
|
|
|
|
|
func _build_hud() -> void:
|
|
var bar_bg1 := ColorRect.new()
|
|
bar_bg1.color = Color("#3a1020")
|
|
bar_bg1.position = Vector2(40, 30)
|
|
bar_bg1.size = Vector2(500, 26)
|
|
add_child(bar_bg1)
|
|
var bar_bg2 := ColorRect.new()
|
|
bar_bg2.color = Color("#3a1020")
|
|
bar_bg2.position = Vector2(740, 30)
|
|
bar_bg2.size = Vector2(500, 26)
|
|
add_child(bar_bg2)
|
|
hud_p1 = ColorRect.new()
|
|
hud_p1.color = Color("#e8c840")
|
|
hud_p1.position = Vector2(40, 30)
|
|
hud_p1.size = Vector2(500, 26)
|
|
add_child(hud_p1)
|
|
hud_p2 = ColorRect.new()
|
|
hud_p2.color = Color("#e8c840")
|
|
hud_p2.position = Vector2(740, 30)
|
|
hud_p2.size = Vector2(500, 26)
|
|
add_child(hud_p2)
|
|
hud_label_1 = Label.new()
|
|
hud_label_1.position = Vector2(40, 60)
|
|
add_child(hud_label_1)
|
|
hud_label_2 = Label.new()
|
|
hud_label_2.position = Vector2(1100, 60)
|
|
add_child(hud_label_2)
|
|
msg_label = Label.new()
|
|
msg_label.position = Vector2(560, 120)
|
|
msg_label.add_theme_font_size_override("font_size", 40)
|
|
add_child(msg_label)
|
|
var help := Label.new()
|
|
help.text = "P1: WASD + J/K (QCF+J special) P2: arrows + O/P F1: hitboxes R: reset"
|
|
help.position = Vector2(320, 690)
|
|
help.modulate = Color(1, 1, 1, 0.4)
|
|
add_child(help)
|
|
if p1:
|
|
hud_label_1.text = p1.data.display_name
|
|
if p2:
|
|
hud_label_2.text = p2.data.display_name
|
|
|
|
|
|
## ---------------------------------------------------------------- input
|
|
|
|
func _poll_p1() -> int:
|
|
var m := 0
|
|
if Input.is_key_pressed(KEY_W): m |= InputBuffer.UP
|
|
if Input.is_key_pressed(KEY_S): m |= InputBuffer.DOWN
|
|
if Input.is_key_pressed(KEY_A): m |= 1 << 8 # raw left
|
|
if Input.is_key_pressed(KEY_D): m |= 1 << 9 # raw right
|
|
if Input.is_key_pressed(KEY_J): m |= InputBuffer.P
|
|
if Input.is_key_pressed(KEY_K): m |= InputBuffer.K
|
|
return m
|
|
|
|
|
|
func _poll_p2() -> int:
|
|
var m := 0
|
|
if Input.is_key_pressed(KEY_UP): m |= InputBuffer.UP
|
|
if Input.is_key_pressed(KEY_DOWN): m |= InputBuffer.DOWN
|
|
if Input.is_key_pressed(KEY_LEFT): m |= 1 << 8
|
|
if Input.is_key_pressed(KEY_RIGHT): m |= 1 << 9
|
|
if Input.is_key_pressed(KEY_O): m |= InputBuffer.P
|
|
if Input.is_key_pressed(KEY_P): m |= InputBuffer.K
|
|
return m
|
|
|
|
|
|
## Convert raw left/right bits into facing-relative FWD/BACK.
|
|
static func _relative(mask: int, facing: int) -> int:
|
|
var out := mask & (InputBuffer.UP | InputBuffer.DOWN | InputBuffer.P | InputBuffer.K)
|
|
var left := (mask & (1 << 8)) != 0
|
|
var right := (mask & (1 << 9)) != 0
|
|
if right and not left:
|
|
out |= InputBuffer.FWD if facing == 1 else InputBuffer.BACK
|
|
elif left and not right:
|
|
out |= InputBuffer.BACK if facing == 1 else InputBuffer.FWD
|
|
return out
|
|
|
|
|
|
## ---------------------------------------------------------------- tick
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
if p1 == null or p2 == null:
|
|
return
|
|
if Input.is_key_pressed(KEY_R) and match_over:
|
|
get_tree().reload_current_scene()
|
|
return
|
|
if Input.is_physical_key_pressed(KEY_F1):
|
|
overlay.visible = true
|
|
|
|
tick_count += 1
|
|
var m1 := _poll_p1()
|
|
var m2 := _poll_p2()
|
|
if shot_path != "":
|
|
m1 = _demo_input(tick_count)
|
|
m2 = 0
|
|
|
|
if not match_over:
|
|
var r1 := _relative(m1, p1.facing)
|
|
var r2 := _relative(m2, p2.facing)
|
|
p1.tick(r1)
|
|
p2.tick(r2)
|
|
_update_facing()
|
|
_separate_pushboxes()
|
|
_resolve_hits(p1, p2, r2)
|
|
_resolve_hits(p2, p1, r1)
|
|
_tick_finish_flow()
|
|
|
|
_update_hud()
|
|
overlay.queue_redraw()
|
|
|
|
if shot_path != "" and tick_count == 260:
|
|
_save_screenshot()
|
|
|
|
|
|
## Scripted input for screenshot mode: walk in, QCF+P special (KOs the
|
|
## 60hp demo dummy), then QCF+K finisher during the FINISH window.
|
|
func _demo_input(t: int) -> int:
|
|
if t < 66:
|
|
return 1 << 9
|
|
if t in [68, 69]:
|
|
return InputBuffer.DOWN
|
|
if t in [70, 71]:
|
|
return InputBuffer.DOWN | (1 << 9)
|
|
if t in [72, 73]:
|
|
return 1 << 9
|
|
if t in [74, 75]:
|
|
return (1 << 9) | InputBuffer.P
|
|
if t in [150, 151]:
|
|
return InputBuffer.DOWN
|
|
if t in [152, 153]:
|
|
return InputBuffer.DOWN | (1 << 9)
|
|
if t in [154, 155]:
|
|
return 1 << 9
|
|
if t in [156, 157]:
|
|
return (1 << 9) | InputBuffer.K
|
|
return 0
|
|
|
|
|
|
func _update_facing() -> void:
|
|
for pair in [[p1, p2], [p2, p1]]:
|
|
var f: Fighter = pair[0]
|
|
var o: Fighter = pair[1]
|
|
if f.can_act():
|
|
f.facing = 1 if o.pos_x >= f.pos_x else -1
|
|
|
|
|
|
func _separate_pushboxes() -> void:
|
|
if p1.airborne() or p2.airborne():
|
|
return
|
|
for f in [p1, p2]:
|
|
if f.state in [Fighter.St.KNOCKDOWN, Fighter.St.KO, Fighter.St.DYING]:
|
|
return
|
|
var dist := absf(p1.pos_x - p2.pos_x)
|
|
if dist < CFG.PUSHBOX_W:
|
|
var push := minf((CFG.PUSHBOX_W - dist) / 2.0, CFG.PUSHBOX_SEP_SPEED)
|
|
var sign_dir := 1.0 if p1.pos_x <= p2.pos_x else -1.0
|
|
p1.pos_x = clampf(p1.pos_x - push * sign_dir, -CFG.STAGE_HALF, CFG.STAGE_HALF)
|
|
p2.pos_x = clampf(p2.pos_x + push * sign_dir, -CFG.STAGE_HALF, CFG.STAGE_HALF)
|
|
|
|
|
|
func _resolve_hits(attacker: Fighter, defender: Fighter, defender_mask: int) -> void:
|
|
var hits := attacker.world_hitboxes()
|
|
if hits.is_empty():
|
|
return
|
|
for hb in hits:
|
|
for hurt in defender.world_hurtboxes():
|
|
if hb.intersects(hurt):
|
|
attacker.has_hit = true
|
|
defender.take_hit(attacker.attack_move, defender.blocking_high(defender_mask))
|
|
return
|
|
|
|
|
|
## KO -> dizzy loser -> FINISH! window -> finisher cinematic or collapse.
|
|
func _tick_finish_flow() -> void:
|
|
if match_over:
|
|
return
|
|
for pair in [[p1, p2, "P2"], [p2, p1, "P1"]]:
|
|
var loser: Fighter = pair[0]
|
|
var winner: Fighter = pair[1]
|
|
var wname: String = pair[2]
|
|
|
|
if loser.state == Fighter.St.DYING:
|
|
if not finisher_running:
|
|
finisher_running = true
|
|
darken.visible = true
|
|
msg_label.text = ""
|
|
if loser.anim_tick >= loser.current_move().total and winner.state != Fighter.St.ATTACK:
|
|
match_over = true
|
|
msg_label.text = "%s WINS — FINISHER" % wname
|
|
return
|
|
|
|
if loser.state == Fighter.St.KO:
|
|
match_over = true
|
|
darken.visible = false
|
|
msg_label.text = "%s WINS" % wname
|
|
return
|
|
|
|
if loser.state == Fighter.St.DIZZY:
|
|
if finish_window < 0:
|
|
finish_window = 300
|
|
finish_window -= 1
|
|
msg_label.text = "FINISH!" if (tick_count / 15) % 2 == 0 else ""
|
|
var in_range: bool = absf(winner.pos_x - loser.pos_x) < 280.0
|
|
winner.finisher_ok = in_range
|
|
if finish_window == 0:
|
|
loser._enter(Fighter.St.KO) # window expired: collapse
|
|
return
|
|
# nobody down
|
|
p1.finisher_ok = false
|
|
p2.finisher_ok = false
|
|
|
|
|
|
func _update_hud() -> void:
|
|
hud_p1.size.x = 500.0 * p1.hp / p1.data.health
|
|
hud_p2.size.x = 500.0 * p2.hp / p2.data.health
|
|
hud_p2.position.x = 740 + (500 - hud_p2.size.x)
|
|
|
|
|
|
func _save_screenshot() -> void:
|
|
await RenderingServer.frame_post_draw
|
|
var img := get_viewport().get_texture().get_image()
|
|
img.save_png(shot_path)
|
|
get_tree().quit()
|