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:
@@ -0,0 +1,60 @@
|
||||
extends MultiMeshInstance2D
|
||||
## Draws the whole bullet field in one draw call.
|
||||
##
|
||||
## A bullet hell routinely has a thousand bullets on screen. One Node2D each
|
||||
## would cost more than the simulation does, so the renderer reads the client's
|
||||
## [BulletPool] arrays directly and pushes them into a MultiMesh. The sprite is
|
||||
## generated at runtime, so the project needs no art to run.
|
||||
|
||||
const TEXTURE_SIZE := 32
|
||||
|
||||
var _colors := {
|
||||
SimConfig.KIND_PLAYER_SHOT: Color(0.45, 0.95, 1.0),
|
||||
SimConfig.KIND_ORB: Color(1.0, 0.35, 0.75),
|
||||
SimConfig.KIND_NEEDLE: Color(1.0, 0.85, 0.3),
|
||||
SimConfig.KIND_HEAVY: Color(1.0, 0.45, 0.2),
|
||||
}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
texture = _make_dot_texture()
|
||||
var mm := MultiMesh.new()
|
||||
mm.transform_format = MultiMesh.TRANSFORM_2D
|
||||
mm.use_colors = true
|
||||
var quad := QuadMesh.new()
|
||||
quad.size = Vector2.ONE
|
||||
mm.mesh = quad
|
||||
mm.instance_count = SimConfig.MAX_BULLETS
|
||||
mm.visible_instance_count = 0
|
||||
multimesh = mm
|
||||
|
||||
|
||||
## A soft-edged disc with a bright core, so overlapping bullets stay readable.
|
||||
func _make_dot_texture() -> ImageTexture:
|
||||
var img := Image.create(TEXTURE_SIZE, TEXTURE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
var centre := Vector2(TEXTURE_SIZE, TEXTURE_SIZE) * 0.5
|
||||
for y in TEXTURE_SIZE:
|
||||
for x in TEXTURE_SIZE:
|
||||
var d := Vector2(x + 0.5, y + 0.5).distance_to(centre) / (TEXTURE_SIZE * 0.5)
|
||||
var alpha := clampf(1.0 - d, 0.0, 1.0)
|
||||
alpha = pow(alpha, 0.6)
|
||||
var core := clampf(1.0 - d * 1.9, 0.0, 1.0)
|
||||
img.set_pixel(x, y, Color(1.0, 1.0, 1.0, alpha).lerp(Color.WHITE, core * 0.8))
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func render_pool(pool: BulletPool) -> void:
|
||||
var mm := multimesh
|
||||
var n := 0
|
||||
for i in pool.high_water:
|
||||
if pool.alive[i] == 0:
|
||||
continue
|
||||
if n >= SimConfig.MAX_BULLETS:
|
||||
break
|
||||
# The texture's visible disc is about 80% of the quad, so scale up a
|
||||
# little to make the drawn radius match the radius used for hits.
|
||||
var d: float = pool.radius[i] * 2.5
|
||||
mm.set_instance_transform_2d(n, Transform2D(0.0, Vector2(d, d), 0.0, pool.pos[i]))
|
||||
mm.set_instance_color(n, _colors.get(pool.kind[i], Color.WHITE))
|
||||
n += 1
|
||||
mm.visible_instance_count = n
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmjyy62xxru42
|
||||
@@ -0,0 +1,29 @@
|
||||
extends Node2D
|
||||
## Root of the playable client scene. Wires the view and HUD to whatever client
|
||||
## runtime [Net] currently has, and survives that runtime being null (during
|
||||
## connection) or replaced (on reconnect).
|
||||
|
||||
@onready var world_view: Node2D = $WorldView
|
||||
@onready var hud: CanvasLayer = $HUD
|
||||
|
||||
var _bound: ClientRuntime = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
get_viewport().size_changed.connect(_recentre)
|
||||
|
||||
|
||||
func _recentre() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if Net.client != _bound:
|
||||
_bound = Net.client
|
||||
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
|
||||
_bound.local_hit.connect(_on_local_hit)
|
||||
|
||||
|
||||
func _on_local_hit(_damage: int) -> void:
|
||||
hud.flash_hit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0xkfsnpm1xe1
|
||||
@@ -0,0 +1,119 @@
|
||||
extends Node2D
|
||||
## Draws everything that is not a bullet. There are only a handful of actors, so
|
||||
## immediate-mode [method CanvasItem.draw_circle] is cheaper than keeping nodes
|
||||
## in sync with a replicated world.
|
||||
##
|
||||
## The view is strictly read-only: it never advances the simulation and never
|
||||
## decides anything. Every number it draws came from the server.
|
||||
|
||||
const COL_ARENA := Color(0.22, 0.24, 0.34)
|
||||
const COL_LOCAL := Color(0.5, 1.0, 0.8)
|
||||
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
||||
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
||||
const COL_PORTAL := Color(0.5, 0.9, 1.0)
|
||||
const ENEMY_COLORS := [
|
||||
Color(0.95, 0.55, 0.55), # drifter
|
||||
Color(0.85, 0.7, 0.35), # turret
|
||||
Color(0.9, 0.4, 0.9), # stalker
|
||||
Color(0.5, 0.55, 0.6), # dummy
|
||||
]
|
||||
|
||||
@onready var bullets: MultiMeshInstance2D = $Bullets
|
||||
|
||||
var client: ClientRuntime = null
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
client = Net.client
|
||||
if client != null:
|
||||
bullets.render_pool(client.world.pool)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
_draw_arena()
|
||||
if client == null:
|
||||
return
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
_draw_portal()
|
||||
for e in client.enemies():
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
for p in client.remote_players():
|
||||
_draw_remote_player(p)
|
||||
_draw_local_player()
|
||||
|
||||
|
||||
func _draw_arena() -> void:
|
||||
var h := SimConfig.ARENA_HALF
|
||||
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
|
||||
# Faint grid, purely so movement reads against a background.
|
||||
var step := 80.0
|
||||
var faint := Color(COL_ARENA, 0.25)
|
||||
var x := -h.x + step
|
||||
while x < h.x:
|
||||
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
|
||||
x += step
|
||||
var y := -h.y + step
|
||||
while y < h.y:
|
||||
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
|
||||
y += step
|
||||
|
||||
|
||||
func _draw_portal() -> void:
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
|
||||
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
|
||||
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
|
||||
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
|
||||
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
||||
|
||||
|
||||
func _draw_enemy(e: Dictionary) -> void:
|
||||
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
|
||||
var r: float = e["radius"]
|
||||
draw_circle(e["pos"], r, Color(col, 0.35))
|
||||
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
|
||||
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty():
|
||||
return
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
var pos: Vector2 = b["pos"]
|
||||
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
|
||||
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
|
||||
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
|
||||
|
||||
|
||||
func _draw_remote_player(p: Dictionary) -> void:
|
||||
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
|
||||
var col := COL_REMOTE if alive else COL_DEAD
|
||||
_draw_ship(p["pos"], p["aim"], col, alive)
|
||||
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
|
||||
_draw_escape_ring(p["pos"], float(p["escape"]), col)
|
||||
|
||||
|
||||
func _draw_local_player() -> void:
|
||||
if not client.my_alive:
|
||||
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
||||
return
|
||||
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
|
||||
if client.my_escaping:
|
||||
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
|
||||
|
||||
|
||||
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
|
||||
var r := SimConfig.PLAYER_RADIUS
|
||||
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
|
||||
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
|
||||
if alive:
|
||||
var dir := Vector2.RIGHT.rotated(aim)
|
||||
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
|
||||
|
||||
|
||||
## The escape channel is drawn on the player, not just in the HUD, so other
|
||||
## players can see someone is about to leave and react.
|
||||
func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void:
|
||||
draw_arc(pos, SimConfig.PLAYER_RADIUS + 8.0, -PI * 0.5,
|
||||
-PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhkx1oxbrhh32
|
||||
Reference in New Issue
Block a user