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:
+15
-2
@@ -11,15 +11,27 @@ var _bound: ClientRuntime = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
_recentre()
|
||||
get_viewport().size_changed.connect(_recentre)
|
||||
menu.return_to_hub_requested.connect(_on_return_to_hub)
|
||||
menu.disconnect_requested.connect(_on_disconnect)
|
||||
hud.respawn_pressed.connect(_on_respawn_pressed)
|
||||
|
||||
|
||||
var _screen_centre := Vector2.ZERO
|
||||
|
||||
|
||||
func _recentre() -> void:
|
||||
world_view.position = get_viewport_rect().size * 0.5
|
||||
_screen_centre = get_viewport_rect().size * 0.5
|
||||
|
||||
|
||||
## Scroll the world so the local player stays centred. Dungeons are larger than
|
||||
## the viewport now, so a fixed camera would simply lose the player off-screen.
|
||||
func _follow_camera() -> void:
|
||||
var focus := Vector2.ZERO
|
||||
if _bound != null:
|
||||
focus = _bound.predicted_pos
|
||||
world_view.position = _screen_centre - focus
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -27,6 +39,7 @@ func _process(_delta: float) -> void:
|
||||
_bound = Net.client
|
||||
if _bound != null and not _bound.local_hit.is_connected(_on_local_hit):
|
||||
_bound.local_hit.connect(_on_local_hit)
|
||||
_follow_camera()
|
||||
menu.set_in_dungeon(_bound != null
|
||||
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||
|
||||
|
||||
+72
-20
@@ -7,6 +7,11 @@ extends Node2D
|
||||
## decides anything. Every number it draws came from the server.
|
||||
|
||||
const COL_ARENA := Color(0.22, 0.24, 0.34)
|
||||
const COL_FLOOR := Color(0.11, 0.12, 0.17)
|
||||
const COL_WALL := Color(0.28, 0.30, 0.40)
|
||||
const COL_PILLAR := Color(0.36, 0.34, 0.44)
|
||||
const COL_PIT := Color(0.03, 0.03, 0.05)
|
||||
const COL_BARRICADE := Color(0.30, 0.26, 0.22)
|
||||
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)
|
||||
@@ -33,40 +38,87 @@ func _process(_delta: float) -> void:
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
_draw_arena()
|
||||
if client == null:
|
||||
return
|
||||
_draw_terrain()
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
_draw_portal()
|
||||
for e in client.enemies():
|
||||
_draw_enemy(e)
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
for p in client.remote_players():
|
||||
_draw_remote_player(p)
|
||||
if _visible(p["pos"]):
|
||||
_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
|
||||
## Terrain, drawn only where the player can currently see it.
|
||||
##
|
||||
## Hard fog: nothing outside line of sight is drawn, including ground already
|
||||
## walked over. Note what this is and is not -- it is a rendering rule, not a
|
||||
## secrecy mechanism. The client holds real tile data because it simulates
|
||||
## bullets, so a modified client could draw what is hidden here. What it cannot
|
||||
## do is see terrain the server never streamed (only a radius around the
|
||||
## player) or actors the server filtered out, which is where the line actually
|
||||
## sits.
|
||||
func _draw_terrain() -> void:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
return
|
||||
var eye := client.predicted_pos
|
||||
var view := SimConfig.FOG_VIEW_RADIUS
|
||||
var lo := map.to_tile(eye - Vector2(view, view))
|
||||
var hi := map.to_tile(eye + Vector2(view, view))
|
||||
var view_sq := view * view
|
||||
for ty in range(lo.y, hi.y + 1):
|
||||
for tx in range(lo.x, hi.x + 1):
|
||||
var kind := map.at(tx, ty)
|
||||
if kind == MapGrid.Kind.UNKNOWN:
|
||||
continue
|
||||
var centre := map.tile_centre(tx, ty)
|
||||
if eye.distance_squared_to(centre) > view_sq:
|
||||
continue
|
||||
if not map.has_line_of_sight(eye, centre):
|
||||
continue
|
||||
_draw_tile(kind, centre)
|
||||
|
||||
|
||||
func _draw_tile(kind: MapGrid.Kind, centre: Vector2) -> void:
|
||||
var half := MapGrid.TILE * 0.5
|
||||
var r := Rect2(centre - Vector2(half, half), Vector2(MapGrid.TILE, MapGrid.TILE))
|
||||
match kind:
|
||||
MapGrid.Kind.FLOOR:
|
||||
draw_rect(r.grow(-1.0), COL_FLOOR)
|
||||
MapGrid.Kind.WALL:
|
||||
draw_rect(r, COL_WALL)
|
||||
MapGrid.Kind.PILLAR:
|
||||
draw_rect(r.grow(-2.0), COL_PILLAR)
|
||||
MapGrid.Kind.PIT:
|
||||
draw_rect(r.grow(-1.0), COL_PIT)
|
||||
MapGrid.Kind.BARRICADE:
|
||||
# Drawn short so "you can see over this" reads at a glance.
|
||||
draw_rect(Rect2(r.position + Vector2(0.0, half * 0.5),
|
||||
Vector2(MapGrid.TILE, half)), COL_BARRICADE)
|
||||
|
||||
|
||||
## Actors are only drawn where the player can actually see them. The server
|
||||
## already withholds distant ones; this hides the ones behind walls.
|
||||
func _visible(at: Vector2) -> bool:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
return true
|
||||
var eye := client.predicted_pos
|
||||
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
|
||||
return false
|
||||
return map.has_line_of_sight(eye, at)
|
||||
|
||||
|
||||
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,
|
||||
draw_arc(client.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,
|
||||
draw_circle(client.portal_pos, SimConfig.PORTAL_RADIUS * 0.25,
|
||||
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
||||
|
||||
|
||||
@@ -94,7 +146,7 @@ func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void:
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty():
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
var pos: Vector2 = b["pos"]
|
||||
|
||||
Reference in New Issue
Block a user