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:
+26
-2
@@ -20,6 +20,10 @@ var team := PackedByteArray()
|
||||
var kind := PackedByteArray()
|
||||
var alive := PackedByteArray()
|
||||
|
||||
## The geometry bullets die against. Set by the owning SimWorld; identical on
|
||||
## server and client, which is what keeps the replica in step.
|
||||
var map: MapGrid = null
|
||||
|
||||
var live_count := 0
|
||||
## Exclusive upper bound over slots that have ever been used, so the hot loops
|
||||
## do not walk the whole 4096-slot table while the arena is nearly empty.
|
||||
@@ -33,6 +37,13 @@ var _next_uid := 1
|
||||
## arrive from the wire already.
|
||||
var spawn_log := PackedInt32Array()
|
||||
|
||||
## uids of bullets killed by hitting solid geometry this tick. Lifetime and
|
||||
## out-of-bounds deaths are NOT recorded: both sides can derive those from the
|
||||
## spawn event alone. A wall death cannot be derived by a client that has not
|
||||
## been streamed that wall yet, so the server announces those explicitly --
|
||||
## otherwise partial map knowledge shows bullets sailing through walls.
|
||||
var wall_kill_log := PackedInt32Array()
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
var n := SimConfig.MAX_BULLETS
|
||||
@@ -55,6 +66,7 @@ func clear() -> void:
|
||||
uid[i] = 0
|
||||
_free.clear()
|
||||
spawn_log.clear()
|
||||
wall_kill_log.clear()
|
||||
live_count = 0
|
||||
high_water = 0
|
||||
|
||||
@@ -97,6 +109,7 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
|
||||
|
||||
func clear_spawn_log() -> void:
|
||||
spawn_log.clear()
|
||||
wall_kill_log.clear()
|
||||
|
||||
|
||||
func despawn(slot: int) -> void:
|
||||
@@ -134,7 +147,17 @@ func step() -> void:
|
||||
var p: Vector2 = pos[i] + v * dt
|
||||
pos[i] = p
|
||||
life[i] -= 1
|
||||
if life[i] <= 0 or Movement.outside_arena(p):
|
||||
if life[i] <= 0:
|
||||
despawn(i)
|
||||
continue
|
||||
# Bounds first: out-of-bounds tiles read as WALL by design (that is what
|
||||
# seals the world), so testing geometry first would report every bullet
|
||||
# that simply left the map as a wall kill and announce it needlessly.
|
||||
if Movement.outside_map(p, map):
|
||||
despawn(i)
|
||||
continue
|
||||
if map != null and map.bullet_blocked(p):
|
||||
wall_kill_log.append(uid[i])
|
||||
despawn(i)
|
||||
|
||||
|
||||
@@ -156,6 +179,7 @@ func advance_slot(slot: int, ticks: int) -> void:
|
||||
vel[slot] = v
|
||||
pos[slot] = pos[slot] + v * dt
|
||||
life[slot] -= 1
|
||||
if life[slot] <= 0 or Movement.outside_arena(pos[slot]):
|
||||
if life[slot] <= 0 or Movement.bullet_stopped(pos[slot], map):
|
||||
despawn(slot)
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
class_name MapGen
|
||||
extends RefCounted
|
||||
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
|
||||
## arena (see [Rooms]).
|
||||
##
|
||||
## Deterministic from (seed, depth) alone, so the server can hand a client the
|
||||
## same two numbers instead of a map, and a failing run can be reproduced from
|
||||
## its log line.
|
||||
|
||||
## The single entry point both server and client use.
|
||||
##
|
||||
## Maps are never sent as tile data: they are a pure function of
|
||||
## (kind, seed, depth), so the server ships three integers and the client
|
||||
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
|
||||
## that makes that safe, and it keeps a big dungeon free on the wire.
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||
if kind == Protocol.InstanceKind.LOBBY:
|
||||
return _build_lobby()
|
||||
return generate(seed_value, depth)
|
||||
|
||||
|
||||
static func _build_lobby() -> Dictionary:
|
||||
var stamp := Rooms.lobby()
|
||||
var size := Rooms.size_of(stamp)
|
||||
var grid := MapGrid.new(size.x, size.y, MapGrid.Kind.WALL)
|
||||
grid.centre_on_origin()
|
||||
var markers := Rooms.stamp(grid, stamp, Vector2i.ZERO)
|
||||
var spawn := grid.tile_centre(size.x / 2, size.y - 4)
|
||||
if not markers["S"].is_empty():
|
||||
var m: Vector2i = markers["S"][0]
|
||||
spawn = grid.tile_centre(m.x, m.y)
|
||||
var portal := grid.tile_centre(size.x / 2, 3)
|
||||
if not markers["P"].is_empty():
|
||||
var m: Vector2i = markers["P"][0]
|
||||
portal = grid.tile_centre(m.x, m.y)
|
||||
var target := grid.tile_centre(size.x / 4, size.y / 2)
|
||||
if not markers["T"].is_empty():
|
||||
var m: Vector2i = markers["T"][0]
|
||||
target = grid.tile_centre(m.x, m.y)
|
||||
return {
|
||||
"grid": grid,
|
||||
"rooms": [] as Array[Rect2i],
|
||||
"spawn": spawn,
|
||||
"portal": portal,
|
||||
"dummy": target,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
}
|
||||
|
||||
|
||||
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
|
||||
## (Vector2), boss_room (Rect2i).
|
||||
static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed_value
|
||||
var d := maxi(depth, 1)
|
||||
|
||||
# Size grows with depth. Depth 1 is a little under two screens across --
|
||||
# enough that fog and exploration mean something without a first run
|
||||
# becoming a hike.
|
||||
var w := 56 + d * 10
|
||||
var h := 36 + d * 7
|
||||
var grid := MapGrid.new(w, h, MapGrid.Kind.WALL)
|
||||
grid.centre_on_origin()
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
var boss_room := Rect2i(boss_origin, bs)
|
||||
|
||||
var boss_pos := grid.tile_centre(
|
||||
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
|
||||
if not markers["B"].is_empty():
|
||||
var m: Vector2i = markers["B"][0]
|
||||
boss_pos = grid.tile_centre(m.x, m.y)
|
||||
|
||||
var door := Vector2i(boss_origin.x, boss_origin.y + bs.y / 2)
|
||||
if not markers["D"].is_empty():
|
||||
door = markers["D"][0]
|
||||
|
||||
# Generated rooms, confined to the space left of the arena.
|
||||
var rooms: Array[Rect2i] = []
|
||||
var usable_w := boss_origin.x - 2
|
||||
var attempts := 0
|
||||
var wanted := 5 + d
|
||||
while rooms.size() < wanted and attempts < wanted * 40:
|
||||
attempts += 1
|
||||
var rw := rng.randi_range(6, 12)
|
||||
var rh := rng.randi_range(5, 10)
|
||||
var rx := rng.randi_range(1, maxi(usable_w - rw - 1, 2))
|
||||
var ry := rng.randi_range(1, maxi(h - rh - 2, 2))
|
||||
var candidate := Rect2i(rx, ry, rw, rh)
|
||||
# One tile of padding so two rooms never share a wall and merge into an
|
||||
# ambiguous blob.
|
||||
var padded := candidate.grow(1)
|
||||
var clash := false
|
||||
for existing in rooms:
|
||||
if padded.intersects(existing.grow(1)):
|
||||
clash = true
|
||||
break
|
||||
if clash:
|
||||
continue
|
||||
rooms.append(candidate)
|
||||
grid.fill_rect(candidate, MapGrid.Kind.FLOOR)
|
||||
|
||||
# Connect each room to the previous one, then the last to the arena door,
|
||||
# so every room is reachable by construction rather than by luck.
|
||||
for i in range(1, rooms.size()):
|
||||
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i]))
|
||||
if not rooms.is_empty():
|
||||
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door)
|
||||
# The door tile itself, and the tile outside it, must be floor.
|
||||
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
|
||||
grid.set_tile(door.x - 1, door.y, MapGrid.Kind.FLOOR)
|
||||
|
||||
_scatter_cover(grid, rooms, rng)
|
||||
|
||||
var spawn := boss_pos
|
||||
if not rooms.is_empty():
|
||||
var c := _centre_of(rooms[0])
|
||||
# Cover is scattered before the spawn is chosen, so the spawn tile can
|
||||
# have had a pillar dropped on it. Clear it rather than hunting for a
|
||||
# free tile: this is the one tile in the map that must be standable.
|
||||
grid.set_tile(c.x, c.y, MapGrid.Kind.FLOOR)
|
||||
spawn = grid.tile_centre(c.x, c.y)
|
||||
|
||||
return {
|
||||
"grid": grid,
|
||||
"rooms": rooms,
|
||||
"spawn": spawn,
|
||||
"portal": Vector2.ZERO,
|
||||
"dummy": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
}
|
||||
|
||||
|
||||
static func _centre_of(r: Rect2i) -> Vector2i:
|
||||
return Vector2i(r.position.x + r.size.x / 2, r.position.y + r.size.y / 2)
|
||||
|
||||
|
||||
## L-shaped, with the corner order chosen by parity so corridors do not all
|
||||
## bend the same way.
|
||||
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void:
|
||||
if (from.x + from.y) % 2 == 0:
|
||||
_carve_h(grid, from.x, to.x, from.y)
|
||||
_carve_v(grid, from.y, to.y, to.x)
|
||||
else:
|
||||
_carve_v(grid, from.y, to.y, from.x)
|
||||
_carve_h(grid, from.x, to.x, to.y)
|
||||
|
||||
|
||||
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void:
|
||||
for x in range(mini(x0, x1), maxi(x0, x1) + 1):
|
||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||
|
||||
|
||||
static func _carve_v(grid: MapGrid, y0: int, y1: int, x: int) -> void:
|
||||
for y in range(mini(y0, y1), maxi(y0, y1) + 1):
|
||||
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
|
||||
|
||||
|
||||
## A few pillars and pits inside generated rooms, so open rooms still have
|
||||
## something to fight around. Never placed on a room's edge, where they could
|
||||
## seal a corridor mouth.
|
||||
static func _scatter_cover(grid: MapGrid, rooms: Array[Rect2i], rng: RandomNumberGenerator) -> void:
|
||||
for r in rooms:
|
||||
if r.size.x < 7 or r.size.y < 6:
|
||||
continue
|
||||
var centre := _centre_of(r)
|
||||
var count := rng.randi_range(1, 3)
|
||||
for _i in count:
|
||||
var tx := rng.randi_range(r.position.x + 2, r.end.x - 3)
|
||||
var ty := rng.randi_range(r.position.y + 2, r.end.y - 3)
|
||||
# Room centres are where corridors meet and where the spawn goes;
|
||||
# blocking one can pinch a junction shut.
|
||||
if tx == centre.x and ty == centre.y:
|
||||
continue
|
||||
var kind := MapGrid.Kind.PILLAR if rng.randf() < 0.6 else MapGrid.Kind.PIT
|
||||
grid.set_tile(tx, ty, kind)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfj588h3drql3
|
||||
@@ -0,0 +1,280 @@
|
||||
class_name MapGrid
|
||||
extends RefCounted
|
||||
## The static geometry of one world: a tile grid with per-tile movement, bullet
|
||||
## and sight blocking.
|
||||
##
|
||||
## A grid rather than freeform shapes because three separate systems need to ask
|
||||
## spatial questions cheaply and identically on both server and client -- circle
|
||||
## collision, bullet collision, and line of sight for fog and interest
|
||||
## management. On a grid all three are array lookups; on polygons they are
|
||||
## intersection tests, and the fog algorithm in particular stops being tractable.
|
||||
##
|
||||
## Coordinates: the grid's top-left tile is world (0, 0), and world space runs
|
||||
## to (width * TILE, height * TILE). The old centre-origin arena is gone -- with
|
||||
## maps of varying size there is no meaningful centre to anchor to.
|
||||
|
||||
const TILE := 32.0
|
||||
|
||||
enum Kind {
|
||||
FLOOR,
|
||||
## Full-height: stops movement, bullets and sight.
|
||||
WALL,
|
||||
## Same as WALL, drawn differently. Kept distinct so generators can place
|
||||
## cover without it reading as a room boundary.
|
||||
PILLAR,
|
||||
## Cross it with a bullet or your eyes, but not with your feet.
|
||||
PIT,
|
||||
## Chest height: blocks movement and bullets, but you can see over it.
|
||||
BARRICADE,
|
||||
## Not yet streamed to this client. Only ever appears in a client's copy --
|
||||
## a server map is fully known by construction. Treated as empty so an
|
||||
## un-streamed region cannot wrongly stop a prediction; the stream radius is
|
||||
## kept well ahead of the player so this never decides anything visible.
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
## Tiles per chunk edge. Small enough that a player near one corner of a map
|
||||
## learns a small fraction of it, which is the entire point of streaming rather
|
||||
## than sending the map (or its seed) up front.
|
||||
const CHUNK := 8
|
||||
|
||||
## Parallel flag tables, indexed by Kind. Three independent booleans rather than
|
||||
## one "solid" flag, because the interesting tiles are exactly the ones that
|
||||
## block some things and not others.
|
||||
const BLOCKS_MOVE := [false, true, true, true, true, false]
|
||||
const BLOCKS_BULLET := [false, true, true, false, true, false]
|
||||
const BLOCKS_SIGHT := [false, true, true, false, false, false]
|
||||
|
||||
var width: int = 0
|
||||
var height: int = 0
|
||||
## Row-major, width * height entries of Kind.
|
||||
var tiles := PackedByteArray()
|
||||
## World position of tile (0, 0)'s top-left corner. Generated maps set this to
|
||||
## -world_size()/2 so the world stays centred on the origin, which keeps every
|
||||
## existing coordinate (spawn points, portal, boss placement) meaningful and
|
||||
## avoids an all-positive coordinate space where "0" is a corner.
|
||||
var origin := Vector2.ZERO
|
||||
|
||||
|
||||
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
|
||||
resize(w, h, fill)
|
||||
|
||||
|
||||
func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
|
||||
width = maxi(w, 1)
|
||||
height = maxi(h, 1)
|
||||
tiles.resize(width * height)
|
||||
tiles.fill(fill)
|
||||
|
||||
|
||||
func in_bounds(tx: int, ty: int) -> bool:
|
||||
return tx >= 0 and ty >= 0 and tx < width and ty < height
|
||||
|
||||
|
||||
## Out-of-bounds reads as WALL so callers never have to bounds-check before
|
||||
## asking; the world is sealed by construction.
|
||||
func at(tx: int, ty: int) -> Kind:
|
||||
if not in_bounds(tx, ty):
|
||||
return Kind.WALL
|
||||
return tiles[ty * width + tx] as Kind
|
||||
|
||||
|
||||
func set_tile(tx: int, ty: int, kind: Kind) -> void:
|
||||
if in_bounds(tx, ty):
|
||||
tiles[ty * width + tx] = kind
|
||||
|
||||
|
||||
func fill_rect(rect: Rect2i, kind: Kind) -> void:
|
||||
for ty in range(rect.position.y, rect.end.y):
|
||||
for tx in range(rect.position.x, rect.end.x):
|
||||
set_tile(tx, ty, kind)
|
||||
|
||||
|
||||
# --- Space conversion -------------------------------------------------------
|
||||
|
||||
func world_size() -> Vector2:
|
||||
return Vector2(float(width), float(height)) * TILE
|
||||
|
||||
|
||||
## Centre of a tile, which is what actors are placed on.
|
||||
func tile_centre(tx: int, ty: int) -> Vector2:
|
||||
return origin + Vector2(float(tx) + 0.5, float(ty) + 0.5) * TILE
|
||||
|
||||
|
||||
func to_tile(world: Vector2) -> Vector2i:
|
||||
var local := world - origin
|
||||
# floor(), never int(): truncation folds -0.5 onto tile 0 and would let an
|
||||
# actor stand half a tile outside the map.
|
||||
return Vector2i(int(floor(local.x / TILE)), int(floor(local.y / TILE)))
|
||||
|
||||
|
||||
## Centre the map on the world origin.
|
||||
func centre_on_origin() -> void:
|
||||
origin = -world_size() * 0.5
|
||||
|
||||
|
||||
## World-space rectangle the map occupies.
|
||||
func world_rect() -> Rect2:
|
||||
return Rect2(origin, world_size())
|
||||
|
||||
|
||||
# --- Queries ----------------------------------------------------------------
|
||||
|
||||
func blocks_move(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_MOVE[at(tx, ty)]
|
||||
|
||||
|
||||
func blocks_bullet(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_BULLET[at(tx, ty)]
|
||||
|
||||
|
||||
func blocks_sight(tx: int, ty: int) -> bool:
|
||||
return BLOCKS_SIGHT[at(tx, ty)]
|
||||
|
||||
|
||||
## True when a bullet at this world point should die. Bullets are small enough
|
||||
## that a point test against the tile they are in is indistinguishable from a
|
||||
## circle test, and it keeps server and client trivially identical.
|
||||
func bullet_blocked(world: Vector2) -> bool:
|
||||
var t := to_tile(world)
|
||||
return blocks_bullet(t.x, t.y)
|
||||
|
||||
|
||||
## Circle-vs-grid overlap for actor collision.
|
||||
func circle_blocked(centre: Vector2, radius: float) -> bool:
|
||||
var lo := to_tile(centre - Vector2(radius, radius))
|
||||
var hi := to_tile(centre + Vector2(radius, radius))
|
||||
for ty in range(lo.y, hi.y + 1):
|
||||
for tx in range(lo.x, hi.x + 1):
|
||||
if not blocks_move(tx, ty):
|
||||
continue
|
||||
if _circle_hits_tile(centre, radius, tx, ty):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _circle_hits_tile(centre: Vector2, radius: float, tx: int, ty: int) -> bool:
|
||||
# Closest point on the tile's AABB to the circle centre.
|
||||
var lo := origin + Vector2(float(tx), float(ty)) * TILE
|
||||
var closest := Vector2(
|
||||
clampf(centre.x, lo.x, lo.x + TILE),
|
||||
clampf(centre.y, lo.y, lo.y + TILE))
|
||||
return centre.distance_squared_to(closest) < radius * radius
|
||||
|
||||
|
||||
## Move a circle by [param delta], resolving each axis separately so that
|
||||
## running into a wall at an angle slides along it instead of stopping dead.
|
||||
func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
|
||||
var out := pos
|
||||
var try_x := Vector2(out.x + delta.x, out.y)
|
||||
if not circle_blocked(try_x, radius):
|
||||
out = try_x
|
||||
var try_y := Vector2(out.x, out.y + delta.y)
|
||||
if not circle_blocked(try_y, radius):
|
||||
out = try_y
|
||||
return out
|
||||
|
||||
|
||||
## Bresenham-style sight test between two world points. Used for fog on the
|
||||
## client and for aggro on the server, so it has to agree on both.
|
||||
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
|
||||
var a := to_tile(from)
|
||||
var b := to_tile(to)
|
||||
var dx := absi(b.x - a.x)
|
||||
var dy := -absi(b.y - a.y)
|
||||
var sx := 1 if a.x < b.x else -1
|
||||
var sy := 1 if a.y < b.y else -1
|
||||
var err := dx + dy
|
||||
var x := a.x
|
||||
var y := a.y
|
||||
# Guard against a pathological ray in a huge map costing unbounded time.
|
||||
var steps := 0
|
||||
var limit := width + height + 4
|
||||
while steps < limit:
|
||||
steps += 1
|
||||
if x == b.x and y == b.y:
|
||||
return true
|
||||
# The endpoints themselves never block: standing in a doorway, or
|
||||
# shooting at something embedded in a wall, must still resolve.
|
||||
if not (x == a.x and y == a.y) and blocks_sight(x, y):
|
||||
return false
|
||||
var e2 := 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y += sy
|
||||
return false
|
||||
|
||||
|
||||
# --- Chunked streaming ------------------------------------------------------
|
||||
# The server never sends a whole map, and never sends the seed it was generated
|
||||
# from: either would let a modified client draw the entire dungeon. Tiles are
|
||||
# streamed per peer in chunks around where that player actually is, so a map
|
||||
# hack can reveal a little more than the fog shows and no more.
|
||||
|
||||
func chunks_wide() -> int:
|
||||
return int(ceil(float(width) / float(CHUNK)))
|
||||
|
||||
|
||||
func chunks_high() -> int:
|
||||
return int(ceil(float(height) / float(CHUNK)))
|
||||
|
||||
|
||||
func chunk_count() -> int:
|
||||
return chunks_wide() * chunks_high()
|
||||
|
||||
|
||||
func chunk_id_at(tx: int, ty: int) -> int:
|
||||
return (ty / CHUNK) * chunks_wide() + (tx / CHUNK)
|
||||
|
||||
|
||||
## Tile-space rect a chunk covers, clipped to the map.
|
||||
func chunk_rect(chunk_id: int) -> Rect2i:
|
||||
var cw := chunks_wide()
|
||||
if cw <= 0:
|
||||
return Rect2i()
|
||||
var cx := (chunk_id % cw) * CHUNK
|
||||
var cy := (chunk_id / cw) * CHUNK
|
||||
return Rect2i(cx, cy, mini(CHUNK, width - cx), mini(CHUNK, height - cy))
|
||||
|
||||
|
||||
## Chunk ids whose tiles fall within [param radius] world units of [param at].
|
||||
func chunks_near(at: Vector2, radius: float) -> PackedInt32Array:
|
||||
var out := PackedInt32Array()
|
||||
var lo := to_tile(at - Vector2(radius, radius))
|
||||
var hi := to_tile(at + Vector2(radius, radius))
|
||||
var cw := chunks_wide()
|
||||
var ch := chunks_high()
|
||||
var c_lo_x := clampi(lo.x / CHUNK, 0, cw - 1)
|
||||
var c_hi_x := clampi(hi.x / CHUNK, 0, cw - 1)
|
||||
var c_lo_y := clampi(lo.y / CHUNK, 0, ch - 1)
|
||||
var c_hi_y := clampi(hi.y / CHUNK, 0, ch - 1)
|
||||
for cy in range(c_lo_y, c_hi_y + 1):
|
||||
for cx in range(c_lo_x, c_hi_x + 1):
|
||||
out.append(cy * cw + cx)
|
||||
return out
|
||||
|
||||
|
||||
func encode_chunk(chunk_id: int) -> PackedByteArray:
|
||||
var r := chunk_rect(chunk_id)
|
||||
var out := PackedByteArray()
|
||||
out.resize(r.size.x * r.size.y)
|
||||
var i := 0
|
||||
for ty in range(r.position.y, r.end.y):
|
||||
for tx in range(r.position.x, r.end.x):
|
||||
out[i] = tiles[ty * width + tx]
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
|
||||
var r := chunk_rect(chunk_id)
|
||||
if data.size() != r.size.x * r.size.y:
|
||||
return # malformed or from a different map; ignore rather than corrupt
|
||||
var i := 0
|
||||
for ty in range(r.position.y, r.end.y):
|
||||
for tx in range(r.position.x, r.end.x):
|
||||
tiles[ty * width + tx] = data[i]
|
||||
i += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cly0bi7sxe5gf
|
||||
@@ -15,6 +15,11 @@ var local_tick := 0
|
||||
## How many times this emitter has fired in this phase. Drives spin/step.
|
||||
var shot_index := 0
|
||||
var rng: RandomNumberGenerator
|
||||
## The space this pattern should fill: a boss's arena, or a box around a trash
|
||||
## enemy. Curtain-style emitters span it. Was a global arena constant, which
|
||||
## stopped meaning anything once maps varied in size and fights happened in
|
||||
## rooms rather than in "the arena".
|
||||
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
|
||||
@@ -15,18 +15,22 @@ extends BulletEmitter
|
||||
@export var randomize_gap: bool = false
|
||||
|
||||
|
||||
func _axis() -> Dictionary:
|
||||
## Direction of travel, the axis the curtain is strung along, and how far the
|
||||
## curtain reaches -- all measured from the room the pattern is filling rather
|
||||
## than from a fixed arena.
|
||||
func _axis(bounds: Rect2) -> Dictionary:
|
||||
var half := bounds.size * 0.5
|
||||
match direction:
|
||||
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": half.x, "edge": half.y}
|
||||
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": half.y, "edge": half.x}
|
||||
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": half.y, "edge": half.x}
|
||||
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": half.x, "edge": half.y}
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if count <= 0:
|
||||
return
|
||||
var ax := _axis()
|
||||
var ax := _axis(ctx.bounds)
|
||||
var dir: Vector2 = ax["dir"]
|
||||
var along: Vector2 = ax["along"]
|
||||
var extent: float = ax["extent"]
|
||||
@@ -38,7 +42,8 @@ func fire(ctx: EmitContext) -> void:
|
||||
else:
|
||||
gap = posmod(gap_index + gap_step * ctx.shot_index, maxi(count - gap_width + 1, 1))
|
||||
|
||||
var start := -dir * (edge + 8.0)
|
||||
var centre := ctx.bounds.get_center()
|
||||
var start := centre - dir * (edge + 8.0)
|
||||
var angle := dir.angle()
|
||||
for i in count:
|
||||
if i >= gap and i < gap + gap_width:
|
||||
|
||||
@@ -11,6 +11,10 @@ var alive: bool = true
|
||||
var phase_index: int = 0
|
||||
## Ticks since entering the current phase.
|
||||
var phase_tick: int = 0
|
||||
## The arena this boss may occupy, in world space. A boss never leaves it, so a
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
@@ -15,6 +15,10 @@ var local_tick: int = 0
|
||||
## Staggers identical enemies so a pack does not fire in lockstep.
|
||||
var phase_offset: int = 0
|
||||
var target_dir := Vector2.ZERO
|
||||
## Whoever this enemy is currently aggroed on: within range and in line of
|
||||
## sight. Refreshed by the world every tick; null means idle, which is the
|
||||
## normal state for most of a dungeon.
|
||||
var target: SimPlayer = null
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
+91
-33
@@ -25,8 +25,15 @@ var boss: SimBoss = null
|
||||
## Drained by the owner every tick. See [SimEvent].
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
## The world's static geometry. Never null: worlds without a generated map get
|
||||
## a plain walled room, which keeps every caller free of null checks and lets a
|
||||
## unit test build a world without thinking about terrain.
|
||||
var map: MapGrid = null
|
||||
|
||||
## Set on a lobby world so the interact button can open a dungeon.
|
||||
var portal_enabled: bool = false
|
||||
## Where the hub's dungeon portal sits. Per-world now that maps vary in size.
|
||||
var portal_pos := Vector2.ZERO
|
||||
var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
@@ -39,6 +46,22 @@ var _ctx := EmitContext.new()
|
||||
func _init(seed_value: int = 0) -> void:
|
||||
rng.seed = seed_value
|
||||
_ctx.rng = rng
|
||||
set_map(_default_room())
|
||||
|
||||
|
||||
## A walled box roughly the size of the old fixed arena, so a world built with
|
||||
## no map behaves the way the game did before terrain existed.
|
||||
static func _default_room() -> MapGrid:
|
||||
var g := MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
||||
g.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
||||
g.centre_on_origin()
|
||||
return g
|
||||
|
||||
|
||||
func set_map(new_map: MapGrid) -> void:
|
||||
map = new_map
|
||||
# The pool culls bullets against the same geometry, on both sides.
|
||||
pool.map = new_map
|
||||
|
||||
|
||||
func next_actor_id() -> int:
|
||||
@@ -140,6 +163,7 @@ func step() -> void:
|
||||
_step_boss()
|
||||
pool.step()
|
||||
_resolve_bullet_hits()
|
||||
_emit_wall_kill_events()
|
||||
_emit_spawn_events()
|
||||
else:
|
||||
# Replica: bullets only. Actor state arrives in snapshots.
|
||||
@@ -167,7 +191,7 @@ func _step_players() -> void:
|
||||
continue
|
||||
|
||||
p.aim = frame.aim
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, SimConfig.ARENA_HALF)
|
||||
p.pos = Movement.step_player(p.pos, frame.move, SimConfig.PLAYER_SPEED, map)
|
||||
|
||||
if frame.pressed(InputFrame.BTN_FIRE) and p.can_fire():
|
||||
_fire_player_shot(p)
|
||||
@@ -175,7 +199,7 @@ func _step_players() -> void:
|
||||
_step_escape(p, frame)
|
||||
|
||||
if portal_enabled and frame.pressed(InputFrame.BTN_INTERACT):
|
||||
if p.pos.distance_to(SimConfig.PORTAL_POS) <= SimConfig.PORTAL_RADIUS:
|
||||
if p.pos.distance_to(portal_pos) <= SimConfig.PORTAL_RADIUS:
|
||||
events.append({"t": SimEvent.Type.PORTAL_USED, "peer": p.peer_id})
|
||||
|
||||
|
||||
@@ -229,10 +253,30 @@ func _step_enemies() -> void:
|
||||
for e in enemies.values():
|
||||
if not e.alive:
|
||||
continue
|
||||
# Aggro is re-evaluated every tick and gates both movement and fire, so
|
||||
# a dungeon full of enemies is quiet until you walk into it -- which is
|
||||
# what makes exploring a decision rather than a countdown.
|
||||
e.target = _aggro_target(e)
|
||||
_move_enemy(e)
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
if e.target != null:
|
||||
_run_emitters(e.def.emitters, e.pos,
|
||||
posmod(e.local_tick + e.phase_offset, maxi(e.def.pattern_loop_ticks, 1)))
|
||||
e.local_tick += 1
|
||||
|
||||
|
||||
## Nearest living player within aggro range and in line of sight. Sight matters
|
||||
## as much as range: an enemy that shoots you through a wall makes cover
|
||||
## meaningless, and one that never loses you makes retreating impossible.
|
||||
func _aggro_target(e: SimEnemy) -> SimPlayer:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
return null
|
||||
var range_sq: float = e.def.aggro_range * e.def.aggro_range
|
||||
if e.pos.distance_squared_to(t.pos) > range_sq:
|
||||
return null
|
||||
if not map.has_line_of_sight(e.pos, t.pos):
|
||||
return null
|
||||
return t
|
||||
|
||||
|
||||
func _move_enemy(e: SimEnemy) -> void:
|
||||
@@ -242,41 +286,41 @@ func _move_enemy(e: SimEnemy) -> void:
|
||||
EnemyDef.Move.STATIC:
|
||||
pass
|
||||
EnemyDef.Move.DRIFT:
|
||||
var next := e.pos + e.heading * speed * dt
|
||||
# Bounce off the arena so a drifter never leaves the fight.
|
||||
if absf(next.x) > SimConfig.ARENA_HALF.x - e.def.radius:
|
||||
# Drifters patrol whether or not they have seen anyone; everything
|
||||
# else only moves once aggroed.
|
||||
var delta := e.heading * speed * dt
|
||||
var next := map.slide_circle(e.pos, delta, e.def.radius)
|
||||
# Reflect on whichever axis the map refused. Works for the outer
|
||||
# wall and an interior pillar alike, with no special cases.
|
||||
if absf(next.x - e.pos.x) < absf(delta.x) - 0.001:
|
||||
e.heading.x = -e.heading.x
|
||||
next.x = clampf(next.x, -SimConfig.ARENA_HALF.x + e.def.radius, SimConfig.ARENA_HALF.x - e.def.radius)
|
||||
if absf(next.y) > SimConfig.ARENA_HALF.y - e.def.radius:
|
||||
if absf(next.y - e.pos.y) < absf(delta.y) - 0.001:
|
||||
e.heading.y = -e.heading.y
|
||||
next.y = clampf(next.y, -SimConfig.ARENA_HALF.y + e.def.radius, SimConfig.ARENA_HALF.y - e.def.radius)
|
||||
e.pos = next
|
||||
EnemyDef.Move.ORBIT:
|
||||
var a := float(e.local_tick + e.phase_offset) * dt * (speed / maxf(e.def.move_param, 1.0))
|
||||
e.pos = e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
var want := e.home + Vector2.RIGHT.rotated(a) * e.def.move_param
|
||||
e.pos = map.slide_circle(e.pos, want - e.pos, e.def.radius)
|
||||
EnemyDef.Move.APPROACH:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
e.target_dir = Vector2.ZERO if t == null else (t.pos - e.pos).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.target_dir = (e.target.pos - e.pos).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
EnemyDef.Move.STRAFE:
|
||||
if e.target == null:
|
||||
return
|
||||
if e.local_tick % maxi(e.def.retarget_interval, 1) == 0:
|
||||
var t := nearest_player(e.pos)
|
||||
if t == null:
|
||||
e.target_dir = Vector2.ZERO
|
||||
else:
|
||||
var to_player := t.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos += e.target_dir * speed * dt
|
||||
e.pos.x = clampf(e.pos.x, -SimConfig.ARENA_HALF.x, SimConfig.ARENA_HALF.x)
|
||||
e.pos.y = clampf(e.pos.y, -SimConfig.ARENA_HALF.y, SimConfig.ARENA_HALF.y)
|
||||
var to_player := e.target.pos - e.pos
|
||||
var d := to_player.length()
|
||||
var radial := 0.0
|
||||
if d > e.def.move_param + 20.0:
|
||||
radial = 1.0
|
||||
elif d < e.def.move_param - 20.0:
|
||||
radial = -1.0
|
||||
var toward := Vector2.ZERO if d < 0.001 else to_player / d
|
||||
e.target_dir = (toward * radial + toward.orthogonal() * 0.7).normalized()
|
||||
e.pos = map.slide_circle(e.pos, e.target_dir * speed * dt, e.def.radius)
|
||||
|
||||
|
||||
func _step_boss() -> void:
|
||||
@@ -292,18 +336,25 @@ func _step_boss() -> void:
|
||||
return
|
||||
if boss.phase_tick >= phase.telegraph_ticks:
|
||||
var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1))
|
||||
_run_emitters(phase.emitters, boss.pos, local)
|
||||
_run_emitters(phase.emitters, boss.pos, local, boss.room)
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int) -> void:
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
bounds: Rect2 = Rect2()) -> void:
|
||||
if emitters.is_empty():
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
# enemy would sweep bullets across the entire dungeon.
|
||||
_ctx.bounds = bounds if bounds.size != Vector2.ZERO \
|
||||
else Rect2(origin - Vector2(400.0, 300.0), Vector2(800.0, 600.0))
|
||||
_ctx.origin = origin
|
||||
_ctx.local_tick = local_tick
|
||||
_ctx.has_target = target != null
|
||||
_ctx.target = origin + Vector2.DOWN * 200.0 if target == null else target.pos
|
||||
@@ -388,6 +439,13 @@ func _damage_boss(amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED})
|
||||
|
||||
|
||||
## Bullets that died against geometry. A client is only streamed the map near
|
||||
## itself, so it cannot be relied on to work these out for itself.
|
||||
func _emit_wall_kill_events() -> void:
|
||||
for dead_uid in pool.wall_kill_log:
|
||||
events.append({"t": SimEvent.Type.BULLET_DESPAWN, "uid": dead_uid})
|
||||
|
||||
|
||||
func _emit_spawn_events() -> void:
|
||||
for slot in pool.spawn_log:
|
||||
if pool.alive[slot] == 0:
|
||||
|
||||
Reference in New Issue
Block a user