Initial commit: Transcience MVP

Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
2026-09-03 16:03:57 +02:00
commit c4beeae38f
385 changed files with 28725 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
extends CanvasLayer
## Heads-up display. Built in code rather than as a scene because every element
## is data-driven -- there is no layout here a designer would want to drag.
const MARGIN := 24.0
const BAR_W := 260.0
const BAR_H := 16.0
var _canvas: Control
var _status: Label
var _hint: Label
var _hit_flash: float = 0.0
var client: ClientRuntime = null
func _ready() -> void:
layer = 10
_canvas = Control.new()
_canvas.set_anchors_preset(Control.PRESET_FULL_RECT)
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
_canvas.draw.connect(_draw_hud)
add_child(_canvas)
_status = _make_label(Vector2(MARGIN, MARGIN))
_hint = _make_label(Vector2(MARGIN, 0.0))
_hint.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_hint.position = Vector2(MARGIN, -56.0)
func _make_label(pos: Vector2) -> Label:
var l := Label.new()
l.position = pos
l.add_theme_font_size_override("font_size", 14)
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
_canvas.add_child(l)
return l
func _process(delta: float) -> void:
client = Net.client
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text()
_hint.text = _hint_text()
_canvas.queue_redraw()
func _status_text() -> String:
if client == null:
return "connecting..."
var where := "LOBBY" if client.instance_kind == Protocol.InstanceKind.LOBBY else "DUNGEON"
return "%s instance %d hp %d/%d %d fps" % [
where, client.instance_id, client.my_hp, SimConfig.PLAYER_MAX_HP,
Engine.get_frames_per_second()]
func _hint_text() -> String:
if client == null:
return ""
if client.instance_kind == Protocol.InstanceKind.LOBBY:
return "WASD move mouse aim LMB fire E on the ring to enter a dungeon"
return "WASD move mouse aim LMB fire hold F to escape to the lobby"
func _draw_hud() -> void:
if client == null:
return
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(SimConfig.PLAYER_MAX_HP),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_boss_bar()
if not client.my_alive:
var size := _canvas.size
_canvas.draw_string(ThemeDB.fallback_font, size * 0.5 - Vector2(70.0, 0.0),
"DOWN -- respawning", HORIZONTAL_ALIGNMENT_LEFT, -1, 22, Color(1.0, 0.4, 0.4))
if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
"%s -- %s" % [client.boss_def.display_name, phase_name],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
func flash_hit() -> void:
_hit_flash = 1.0
+1
View File
@@ -0,0 +1 @@
uid://boq560lv7hkl0
+80
View File
@@ -0,0 +1,80 @@
extends Control
## Connect screen. Built in code: it is four controls, and a scene file for it
## would only be one more thing to keep in sync.
signal join_requested(address: String, port: int)
signal host_requested(port: int)
var _address: LineEdit
var _port: LineEdit
var _name: LineEdit
var _status: Label
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var panel := VBoxContainer.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.custom_minimum_size = Vector2(360.0, 0.0)
panel.add_theme_constant_override("separation", 8)
panel.position = Vector2(-180.0, -160.0)
add_child(panel)
var title := Label.new()
title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 32)
panel.add_child(title)
_name = _field(panel, "name", GameOpts.player_name)
_address = _field(panel, "server address", GameOpts.host_address)
_port = _field(panel, "port", str(GameOpts.port))
var join := Button.new()
join.text = "Join"
join.pressed.connect(_on_join)
panel.add_child(join)
var host := Button.new()
host.text = "Host and play (listen server)"
host.pressed.connect(_on_host)
panel.add_child(host)
var quit := Button.new()
quit.text = "Quit"
quit.pressed.connect(func() -> void: get_tree().quit())
panel.add_child(quit)
_status = Label.new()
_status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5))
panel.add_child(_status)
func _field(parent: Control, label: String, value: String) -> LineEdit:
var l := Label.new()
l.text = label
l.add_theme_font_size_override("font_size", 12)
parent.add_child(l)
var e := LineEdit.new()
e.text = value
parent.add_child(e)
return e
func set_status(text: String) -> void:
_status.text = text
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
GameOpts.player_name = clean
func _on_join() -> void:
_commit_name()
join_requested.emit(_address.text.strip_edges(), int(_port.text))
func _on_host() -> void:
_commit_name()
host_requested.emit(int(_port.text))
+1
View File
@@ -0,0 +1 @@
uid://d30lplh710ji8