foitin/engine/core/match_engine.gd
m3ultra b067bf7f0b Scaffold: deterministic 60Hz combat core, drop-in character format, MODELBEAST pipeline
- Godot 4.7 project; fixed-tick FSM fighter, facing-relative input buffer
  with numpad motion parsing, box collision, training-mode hitbox overlay
- characters/ = self-describing drop-in folders (spec in _spec/); two
  generated placeholder fighters (alpha, beta)
- pipeline/: pack_character.py + autohitbox.py (pose/silhouette hurtboxes)
- STUDY.md: reverse-engineering study of the BKB reference games

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 18:45:44 +10:00

245 lines
6.8 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 tick_count := 0
var match_over := 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()
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)
func _load_fighters() -> void:
roster = CharacterData.scan_roster()
if roster.size() < 1:
push_error("No characters found under characters/")
return
var d1 := CharacterData.load_dir(roster[0])
var d2 := CharacterData.load_dir(roster[mini(1, roster.size() - 1)])
# 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)
_check_ko()
_update_hud()
overlay.queue_redraw()
if shot_path != "" and tick_count == 100:
_save_screenshot()
## Scripted input for screenshot mode: walk into range, then jab on contact.
func _demo_input(t: int) -> int:
if t < 84:
return 1 << 9
if t == 86 or t == 87:
return InputBuffer.P
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
if p1.state in [Fighter.St.KNOCKDOWN, Fighter.St.KO] or p2.state in [Fighter.St.KNOCKDOWN, Fighter.St.KO]:
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
func _check_ko() -> void:
if match_over:
return
for pair in [[p1, "P2 WINS"], [p2, "P1 WINS"]]:
var f: Fighter = pair[0]
if f.hp <= 0:
match_over = true
msg_label.text = pair[1]
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()