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") const BAR_W := 500.0 const BAR_H := 26.0 const SEG_GAP := 4.0 # One color per wardrobe band, by state index (extra states reuse the last). const BAND_COLORS: Array = [ Color("#4aa3e8"), Color("#e8c840"), Color("#e85040"), Color("#9c5ae8"), Color("#50c878"), Color("#e88a2a"), Color("#b0b6c4"), ] var p1: Fighter var p2: Fighter var roster: Array = [] var overlay: Node2D var hud_segs_1: Array = [] # per-band segment dicts (see _build_bar) var hud_segs_2: Array = [] 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 paused := false var pause_ui: Node2D 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 # --demo=finisher (default): scripted special into the FINISH flow, shot at 260. # --demo=bands: jab an idle dummy down through every wardrobe hp band to KO; # shots along the way. --shot-ticks=a,b,c overrides when shots are taken. var shot_path := "" var demo := "finisher" var shot_ticks: Array = [] func _ready() -> void: for arg in OS.get_cmdline_user_args(): if arg.begins_with("--shot="): shot_path = arg.trim_prefix("--shot=") elif arg.begins_with("--demo="): demo = arg.trim_prefix("--demo=") elif arg.begins_with("--shot-ticks="): for s in arg.trim_prefix("--shot-ticks=").split(","): shot_ticks.append(int(s)) if shot_path == "" and OS.get_environment("FOITIN_SHOT") != "": shot_path = ProjectSettings.globalize_path("user://shot.png") if shot_ticks.is_empty(): shot_ticks = [260] if demo == "finisher" else [200, 500, 850, 1150, 1400] shot_ticks.sort() _build_stage() _load_fighters() _build_hud() if shot_path != "" and p2 != null and demo == "finisher": p2.hp = 60 # demo: one special KOs -> exercises the finisher flow overlay = BoxOverlay.new() overlay.engine = self overlay.visible = shot_path != "" or Settings.show_boxes add_child(overlay) _build_pause_ui() func _build_pause_ui() -> void: pause_ui = Node2D.new() pause_ui.visible = false pause_ui.z_index = 100 add_child(pause_ui) var dim := ColorRect.new() dim.color = Color(0, 0, 0, 0.6) dim.size = Vector2(1280, 720) pause_ui.add_child(dim) var lab := Label.new() lab.text = "PAUSED" lab.add_theme_font_size_override("font_size", 52) lab.position = Vector2(555, 200) pause_ui.add_child(lab) var entries := [ ["RESUME (Esc)", func(): _set_paused(false)], ["REMATCH", func(): get_tree().reload_current_scene()], ["CHARACTER SELECT", func(): get_tree().change_scene_to_file("res://engine/scenes/Select.tscn")], ["MAIN MENU", func(): get_tree().change_scene_to_file("res://engine/scenes/Title.tscn")], ] for i in entries.size(): var b := Button.new() b.text = entries[i][0] b.position = Vector2(510, 300 + i * 66) b.size = Vector2(260, 50) b.add_theme_font_size_override("font_size", 22) b.pressed.connect(entries[i][1]) pause_ui.add_child(b) func _set_paused(v: bool) -> void: paused = v pause_ui.visible = v func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_ESCAPE and shot_path == "": _set_paused(not paused) func _build_stage() -> void: var bg := ColorRect.new() bg.color = Color("#181a20") bg.size = Vector2(1280, 720) add_child(bg) # stage backgrounds: random image from user://stages (editor-managed) var stages: Array = [] for f in DirAccess.get_files_at("user://stages"): if f.get_extension().to_lower() in ["png", "jpg", "jpeg", "webp"]: stages.append("user://stages/" + f) if not stages.is_empty(): var tex = MoveData.load_texture_any(stages[randi() % stages.size()]) if tex: var spr := Sprite2D.new() spr.texture = tex spr.centered = false var ts: Vector2 = tex.get_size() var k: float = maxf(1280.0 / ts.x, 720.0 / ts.y) spr.scale = Vector2(k, k) spr.modulate = Color(0.8, 0.8, 0.85) # dim so fighters pop add_child(spr) 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: hud_segs_1 = _build_bar(Vector2(40, 30), p1, 1) hud_segs_2 = _build_bar(Vector2(740, 30), p2, -1) 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 ## One health bar as N wardrobe-band segments โ€” each band is a mini bar the ## attacker punches through (1 plain segment when hp_bands is off). ## drain_dir 1 = P1 style (drains right-to-left), -1 = P2 (mirrored). func _build_bar(origin: Vector2, f: Fighter, drain_dir: int) -> Array: var n := 1 if f != null and f.data.hp_bands: n = maxi(f.data.wardrobe_states.size(), 1) var bg := ColorRect.new() bg.color = Color("#3a1020") bg.position = origin bg.size = Vector2(BAR_W, BAR_H) add_child(bg) var seg_w := (BAR_W - SEG_GAP * (n - 1)) / n var out: Array = [] for i in n: # i = wardrobe state index; state 0 must drain first var slot := (n - 1 - i) if drain_dir == 1 else i var fg := ColorRect.new() fg.color = BAND_COLORS[mini(i, BAND_COLORS.size() - 1)] if n > 1 else Color("#e8c840") fg.position = Vector2(origin.x + slot * (seg_w + SEG_GAP), origin.y) fg.size = Vector2(seg_w, BAR_H) add_child(fg) out.append({ "fg": fg, "x": fg.position.x, "w": seg_w, "dir": drain_dir, "hi": 1.0 - float(i) / n, "lo": 1.0 - float(i + 1) / n, }) return out ## ---------------------------------------------------------------- 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 or paused: 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_bands(tick_count) if demo == "bands" else _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 in shot_ticks: _save_screenshot( shot_path if shot_ticks.size() == 1 else _tick_path(tick_count), tick_count == shot_ticks.back()) ## Bands demo: walk toward the (idle) dummy and jab on a fixed rhythm โ€” ## deterministically punches it down through every wardrobe hp band to KO. ## Forward is released on press ticks so the press reads as a neutral jab; ## the rhythm leaves slack over the jab's 26-tick total so no press lands ## inside the previous jab's recovery and gets eaten. func _demo_input_bands(t: int) -> int: if p2 != null and p2.hp <= 0: return 0 # opponent down: hold still so dizzy/KO shots stay clean if t > 60 and t % 32 == 0: return InputBuffer.P return 1 << 9 # raw right = toward P2 ## 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: _update_bar(hud_segs_1, p1) _update_bar(hud_segs_2, p2) hud_label_1.text = _fighter_label(p1) hud_label_2.text = _fighter_label(p2) static func _update_bar(segs: Array, f: Fighter) -> void: if f == null: return var frac := float(f.hp) / f.data.health for s in segs: var fill: float = clampf((frac - s.lo) / (s.hi - s.lo), 0.0, 1.0) var fg: ColorRect = s.fg fg.size.x = s.w * fill if s.dir == -1: fg.position.x = s.x + s.w * (1.0 - fill) static func _fighter_label(f: Fighter) -> String: if f == null: return "" if f.data.hp_bands and f.data.wardrobe_states.size() > 1: return "%s ยท %s" % [f.data.display_name, str(f.wardrobe_state_name()).to_upper()] return f.data.display_name func _tick_path(t: int) -> String: return "%s-%04d.png" % [shot_path.get_basename(), t] func _save_screenshot(path: String, quit_after: bool) -> void: await RenderingServer.frame_post_draw var img := get_viewport().get_texture().get_image() img.save_png(path) if quit_after: get_tree().quit()