Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
This commit is contained in:
@@ -54,6 +54,8 @@ var roster: Array[Dictionary] = []
|
||||
## Whole seconds until a cleared dungeon returns the party, or
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
## Where this world's dungeon portal is. Per-map now, so it has to be told.
|
||||
var portal_pos := Vector2.ZERO
|
||||
|
||||
## Backstop for input-numbering drift: if the server stops acknowledging new
|
||||
## inputs, our tick numbering has fallen outside its acceptance window and no
|
||||
@@ -93,7 +95,7 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
if my_alive:
|
||||
predicted_pos = Movement.step_player(predicted_pos, frame.move,
|
||||
SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
SimConfig.PLAYER_SPEED, world.map)
|
||||
|
||||
# Send the last few frames every tick. Inputs are unreliable-ordered, so the
|
||||
# redundancy is what covers a dropped packet without a retransmit stall.
|
||||
@@ -160,7 +162,7 @@ func _bot_input() -> InputFrame:
|
||||
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
# Walk onto the portal instead of orbiting, or interact never lands.
|
||||
move = (SimConfig.PORTAL_POS - predicted_pos).normalized()
|
||||
move = (portal_pos - predicted_pos).normalized()
|
||||
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
|
||||
buttons |= InputFrame.BTN_ESCAPE
|
||||
return InputFrame.make(input_tick, move, aim, buttons)
|
||||
@@ -185,16 +187,31 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
|
||||
GameLog.warn("client", "input re-sync: %s" % why)
|
||||
|
||||
|
||||
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
|
||||
# A chunk still in flight when we changed instances describes the wrong map.
|
||||
if from_instance != instance_id or world.map == null:
|
||||
return
|
||||
NetCodec.decode_map_chunks_into(world.map, data)
|
||||
|
||||
|
||||
func on_roster(data: PackedByteArray) -> void:
|
||||
roster = NetCodec.decode_roster(data)
|
||||
hud_dirty.emit()
|
||||
|
||||
|
||||
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
spawn: Vector2) -> void:
|
||||
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
|
||||
instance_id = id
|
||||
instance_kind = kind as Protocol.InstanceKind
|
||||
portal_pos = portal
|
||||
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
|
||||
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
|
||||
# and is filled in by streaming as the player moves, so the client never
|
||||
# holds terrain it has not been near -- there is no seed here to regenerate
|
||||
# from. Origin matches the server's centre_on_origin().
|
||||
var blank := MapGrid.new(maxi(map_w, 1), maxi(map_h, 1), MapGrid.Kind.UNKNOWN)
|
||||
blank.centre_on_origin()
|
||||
world.set_map(blank)
|
||||
world.pool.clear()
|
||||
snap_prev = {}
|
||||
snap_curr = {}
|
||||
@@ -281,7 +298,7 @@ func _reconcile(rec: Dictionary) -> void:
|
||||
var p: Vector2 = rec["pos"]
|
||||
if my_alive:
|
||||
for f in pending:
|
||||
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
p = Movement.step_player(p, f.move, SimConfig.PLAYER_SPEED, world.map)
|
||||
var error := predicted_pos.distance_to(p)
|
||||
if error > 24.0:
|
||||
predicted_pos = p # real divergence: take the server's word
|
||||
|
||||
Reference in New Issue
Block a user