diff --git a/CLAUDE.md b/CLAUDE.md index e18ead3..998065d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit -tools/test.sh # 468 GUT tests, headless (~4s) +tools/test.sh # 534 GUT tests, headless (~4s) tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu @@ -29,7 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats godot --headless --path . --script tools/bench.gd # sim cost per tick python3 tools/build_local_assets.py # rebuild the local-only bullet atlas -SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (needs a display) +SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # menus, HUD, roster, and the fog in a dungeon and a tunnel (needs a display) ``` `diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke @@ -243,8 +243,13 @@ ticks in milliseconds with no SceneTree. enemies and the boss all go through the same lerp. The boss did not for a long time, which is invisible while bosses stand still and looks broken the moment one moves. +- **The fog works by draw order, not by deciding per object.** `WorldView._draw` + draws terrain and every actor within sight range, then the darkness from + `Visibility` over them, then whatever must stay visible. That is what lets a + tile or an enemy be half lit. Anything new that fog may hide goes *before* + `_draw_darkness`; anything it must not goes after. - **A telegraph must be visible through fog.** `WorldView._draw_telegraphs` - ignores line of sight on purpose; everything else in the view respects it. A + is drawn after the darkness on purpose; everything else in the view is under it. A warning you cannot see is an unavoidable hit with extra steps. - **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is the only invulnerable state. Do not reintroduce post-hit immunity — it makes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d7b9ae4..aaef875 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,8 +48,10 @@ This project deliberately does not, for four reasons: The trade is that there is no physics engine, so collision is hand-written. Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)): circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight -shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the -genre convention anyway. +for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre +convention anyway. The client's fog no longer shares that line test: it casts +exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so +shadows cut through tiles rather than snapping to them. That was the right call rather than a compromise: the same grid answers collision, sight, and per-peer interest management as array lookups. On arbitrary @@ -186,7 +188,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.** | File | Role | | --- | --- | -| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. | +| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. | +| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. | | `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. | | `src/view/art.gd` | Every atlas rect and sound path, in one table. | | `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5ea291c..2d9036b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -89,6 +89,28 @@ about as often as a good one. **Hard fog.** *(User, this session.)* No remembered terrain — anything outside current line of sight is not drawn, including ground already walked over. +**The fog is geometry, not a verdict per tile — and a little less strict.** +*(User asked: "no longer grid based ... able to light up cells partially", and +"a bit less strict". How strict was left to me.)* `Visibility` casts rays from +the eye — a fixed sweep plus two aimed either side of every wall corner, which +is what makes each shadow edge a straight line — and the view draws terrain and +actors first, then darkness over everything the rays did not reach. A shadow +edge runs straight through a tile; an enemy rounding a corner appears a slice +at a time instead of popping in whole. + +"Less strict" is three specific things. The face of a wall you can see is lit +26 units deep, measured perpendicular to the face — along the ray it was a +sliver for any wall seen at a grazing angle, and the wall beside you vanished, +which is the complaint the old fog had. Shadows end in an 8-unit fade instead +of a hard line. And the edge of sight fades over its last 64 units instead of +stopping at a circle. Nothing is remembered: still hard fog. + +Two lines this deliberately does not cross: light stops the moment a ray steps +back out of the wall it was in, so it never rounds a corner or reaches the far +side of a one-tile wall; and two blocking tiles that touch only at a corner are +closed, not a crack. Bullets are drawn over the darkness, as they always were — +a shot out of the black is still one you can dodge. + **Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev flag; what raises depth in actual play is still open. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7f84927..94937af 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -40,11 +40,14 @@ and more specific than the table above. ### Known flake -`tools/smoke.sh` failed once in a pre-push hook and then passed on four -consecutive runs with no stray processes. It was not reproduced and is not -diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets -and was competing with a full suite run — but that is a guess. If it recurs, -that is the first thing to check. +`tools/smoke.sh` has failed twice, **both times inside the pre-push hook**, and +then passed on every direct run after: four in a row the first time, eight the +second (six in a background loop plus two more, one of them the hook itself). +Neither failure's logs survived, because the hook runs smoke without `KEEP`. +The first suspicion was machine load, but the second failure had nothing else +running. What is different about the hook is its context — stdin is git's +pipe of ref lines, which every bot inherits. Unconfirmed. Push with +`KEEP=1 git push` so the logs of the next one survive. --- @@ -90,7 +93,7 @@ against itself. | Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables | | Enemies placed per room at creation | done | `Instance._populate()` | | Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | -| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` | +| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` | | Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` | | Aggro: range **and** line of sight | done | `SimWorld._aggro_target` | | Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` | @@ -427,7 +430,7 @@ feet is a coin flip, the same burst with a second of notice is a question. - **`EmitContext` gained an `events` list**, the only thing an emitter can do besides spawn bullets. - **Markers are drawn through fog and through walls.** Everything else in the - view respects line of sight; a warning you cannot see is not a warning. See + view is covered by the darkness; a warning you cannot see is not a warning. See `WorldView._draw_telegraphs`. ### The Cantor of the Vault diff --git a/src/sim/map_grid.gd b/src/sim/map_grid.gd index bd4ebe9..6594162 100644 --- a/src/sim/map_grid.gd +++ b/src/sim/map_grid.gd @@ -54,6 +54,11 @@ var tiles := PackedByteArray() ## existing coordinate (spawn points, portal, boss placement) meaningful and ## avoids an all-positive coordinate space where "0" is a corner. var origin := Vector2.ZERO +## Bumped by every write, so something that derives data from the tiles can tell +## whether it is stale without comparing them. The client's fog extracts wall +## corners from the map and would otherwise have to redo it every frame, or miss +## a chunk arriving. +var revision: int = 0 func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void: @@ -65,6 +70,7 @@ func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void: height = maxi(h, 1) tiles.resize(width * height) tiles.fill(fill) + revision += 1 func in_bounds(tx: int, ty: int) -> bool: @@ -82,6 +88,7 @@ func at(tx: int, ty: int) -> Kind: func set_tile(tx: int, ty: int, kind: Kind) -> void: if in_bounds(tx, ty): tiles[ty * width + tx] = kind + revision += 1 func fill_rect(rect: Rect2i, kind: Kind) -> void: @@ -111,6 +118,7 @@ func to_tile(world: Vector2) -> Vector2i: ## Centre the map on the world origin. func centre_on_origin() -> void: origin = -world_size() * 0.5 + revision += 1 ## World-space rectangle the map occupies. @@ -175,8 +183,14 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2: 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. +## Bresenham-style sight test between two world points, from tile to tile. +## Used for aggro on the server. +## +## It used to drive the client's fog as well, which is why the two once had to +## agree. The fog is exact geometry now (see Visibility), so this is the +## server's question alone -- and it is coarser than what the player sees: its +## diagonal steps pass between two tiles that touch only at a corner, which +## the fog treats as closed. ## ## [param include_end] is false here: the endpoints themselves never block, so ## standing in a doorway, or shooting at something embedded in a wall, still @@ -303,3 +317,4 @@ func apply_chunk(chunk_id: int, data: PackedByteArray) -> void: for tx in range(r.position.x, r.end.x): tiles[ty * width + tx] = data[i] i += 1 + revision += 1 diff --git a/src/view/debug_draw.gd b/src/view/debug_draw.gd index 0141ca0..7dc6b80 100644 --- a/src/view/debug_draw.gd +++ b/src/view/debug_draw.gd @@ -20,6 +20,7 @@ const COL_PIT := Color(0.4, 0.5, 1.0, 0.5) const COL_AGGRO := Color(1.0, 0.5, 0.2, 0.25) const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9) const COL_LOS := Color(0.5, 1.0, 0.8, 0.18) +const COL_SIGHT := Color(1.0, 0.95, 0.55, 0.55) # where each fog ray stopped ## Terrain: one outline per tile, coloured by which of the three flags it sets. @@ -82,3 +83,18 @@ static func draw_bullets(c: CanvasItem, pool: BulletPool) -> void: ## A ring at the fog radius, so "why can I not see that" has a visible answer. static func draw_ranges(c: CanvasItem, pos: Vector2) -> void: c.draw_arc(pos, SimConfig.FOG_VIEW_RADIUS, 0.0, TAU, 64, COL_LOS, 1.0) + + +## The outline the fog's rays traced: where each one stopped. Drawn as the hit +## points rather than the soft edge, because what this answers is "why is THAT +## dark", and the answer is always which ray hit what. +static func draw_sight(c: CanvasItem, sight: Visibility) -> void: + var n := sight.angles.size() + if n < 2: + return + var outline := PackedVector2Array() + outline.resize(n + 1) + for i in n: + outline[i] = sight.eye + sight.dirs[i] * sight.hits[i] + outline[n] = outline[0] + c.draw_polyline(outline, COL_SIGHT, 1.0) diff --git a/src/view/visibility.gd b/src/view/visibility.gd new file mode 100644 index 0000000..6e4c4e7 --- /dev/null +++ b/src/view/visibility.gd @@ -0,0 +1,348 @@ +class_name Visibility +extends RefCounted +## What the local player can see, as geometry rather than a verdict per tile. +## +## The fog this replaces asked one question per tile -- can I see its centre? -- +## and drew or skipped the whole tile on the answer. Every shadow was a +## staircase, a wall beside you vanished whenever its centre happened to sit +## behind its neighbour, and tiles snapped in and out as you walked. This casts +## rays instead and lights the space they reach, so a shadow edge runs straight +## through a tile and half of one can be lit. +## +## Client-side and rendering-only, like the fog it replaces. It decides what is +## DRAWN; what the client is SENT is the server's business -- the actor interest +## radius and map streaming -- and nothing in here can widen that. + +## Rays at fixed angles, on top of the ones aimed at corners. Corners alone give +## an exact outline wherever there is geometry to hit; the sweep covers open +## ground with nothing in it, and keeps the angle between neighbouring rays +## small enough that the straight chord joining them stays inside the fade. +const SWEEP_RAYS := 180 +## How far either side of a corner its two rays are aimed, in radians: one grazes +## past and one hits. That pair is what makes the shadow leaving a corner a +## straight line rather than a smear across the gap to the next ray. +const CORNER_NUDGE := 0.0004 +## How deep into a wall its lit face goes, measured straight in from the face. +## +## Without any, the wall you are looking at is exactly where the dark begins and +## is never drawn -- floor would end in a void. Measured perpendicular to the +## face rather than along the ray, because along the ray a wall you are looking +## ALONG gets a sliver: a fixed distance travelled at a grazing angle barely gets +## under the surface, so the wall right beside you was the one you could not +## see -- the very thing this replaced the old fog to fix. +## +## Under a tile, and light also stops the moment a ray leaves the wall it is +## in, so neither the far side of a one-tile wall nor the space round a corner +## is ever lit by it. +const WALL_DEPTH := 26.0 +## The last part of that depth fades out instead of ending in a hard line. +const FEATHER := 8.0 +## Soft edge at the limit of sight, in place of the old hard circle. +const FADE := 64.0 + +## One entry per ray, sorted by angle in [-PI, PI). +var angles := PackedFloat32Array() +var dirs := PackedVector2Array() +## Where each ray stopped: at the first tile that blocks sight, or at the radius. +var hits := PackedFloat32Array() +## Along each ray: fully lit up to [member starts], dark from [member ends]. +var starts := PackedFloat32Array() +var ends := PackedFloat32Array() +var eye := Vector2.ZERO +var radius: float = 0.0 + +var _sweep := PackedFloat32Array() +## Every corner of sight-blocking geometry near the eye, in world space. Derived +## from the tiles alone, so it is only rebuilt when the map changes or the eye +## moves into another tile. +var _corners := PackedVector2Array() +var _corner_map: int = 0 +var _corner_revision: int = -1 +var _corner_tile := Vector2i(-99999, -99999) +var _corner_radius: float = -1.0 + + +func _init() -> void: + _sweep.resize(SWEEP_RAYS) + for i in SWEEP_RAYS: + _sweep[i] = -PI + TAU * float(i) / float(SWEEP_RAYS) + + +## Recompute everything for an eye at [param at] seeing [param view_radius]. +func compute(map: MapGrid, at: Vector2, view_radius: float) -> void: + eye = at + radius = view_radius + _refresh_corners(map) + + var want := PackedFloat32Array(_sweep) + var reach := view_radius + MapGrid.TILE * 1.5 + var reach_sq := reach * reach + for c in _corners: + var d := c - at + var dsq := d.length_squared() + if dsq > reach_sq or dsq < 0.0001: + continue + var a := d.angle() + want.append(wrapf(a - CORNER_NUDGE, -PI, PI)) + want.append(wrapf(a + CORNER_NUDGE, -PI, PI)) + want.sort() + + var n := want.size() + angles = want + dirs.resize(n) + hits.resize(n) + starts.resize(n) + ends.resize(n) + var fade_from := view_radius - FADE + for i in n: + var dir := Vector2.from_angle(want[i]) + dirs[i] = dir + var traced := trace(map, at, dir, view_radius, WALL_DEPTH, FEATHER) + hits[i] = traced.x + var s := fade_from + var e := view_radius + if traced.x < view_radius: + s = minf(traced.y, fade_from) + e = minf(traced.z, view_radius) + starts[i] = s + ends[i] = maxf(e, s + 0.5) + + +## How lit [param p] is, from 1 (fully) to 0 (dark). Matches what the shadow mesh +## draws: the outline between two neighbouring rays is the straight chord +## joining their ends, exactly as the triangles are laid out. +func light_at(p: Vector2) -> float: + var n := angles.size() + if n == 0: + return 1.0 + var d := p - eye + var dist := d.length() + if dist < 0.001: + return 1.0 + var u := d / dist + var a := wrapf(d.angle(), -PI, PI) + var j := angles.bsearch(a) % n + var i := (j - 1 + n) % n + var s := _along(u, dirs[i] * starts[i], dirs[j] * starts[j]) + var e := _along(u, dirs[i] * ends[i], dirs[j] * ends[j]) + if dist <= s: + return 1.0 + if dist >= e: + return 0.0 + return 1.0 - (dist - s) / maxf(e - s, 0.001) + + +## Darkness covering everything not lit, out to [param far], as one triangle +## list: { "points", "colors", "indices" }. Three vertices per ray -- where the +## fade starts (clear), where it is complete (dark) and the far edge (dark) -- +## and four triangles per pair of neighbouring rays. +## +## One batch rather than a polygon per segment: there are several hundred +## segments a frame, and a draw call each was the expensive part. +func shadow_mesh(far: float, colour: Color) -> Dictionary: + var n := angles.size() + var points := PackedVector2Array() + var colors := PackedColorArray() + var indices := PackedInt32Array() + points.resize(n * 3) + colors.resize(n * 3) + indices.resize(n * 12) + var clear := Color(colour, 0.0) + var dark := Color(colour, 1.0) + for i in n: + var dir := dirs[i] + points[i * 3] = eye + dir * starts[i] + points[i * 3 + 1] = eye + dir * ends[i] + points[i * 3 + 2] = eye + dir * far + colors[i * 3] = clear + colors[i * 3 + 1] = dark + colors[i * 3 + 2] = dark + for i in n: + var j := (i + 1) % n + var si := i * 3 + var sj := j * 3 + var k := i * 12 + # The fade: clear at the inner edge, dark at the outer. + indices[k] = si + indices[k + 1] = sj + indices[k + 2] = sj + 1 + indices[k + 3] = si + indices[k + 4] = sj + 1 + indices[k + 5] = si + 1 + # Solid dark from there out to the far edge. + indices[k + 6] = si + 1 + indices[k + 7] = sj + 1 + indices[k + 8] = sj + 2 + indices[k + 9] = si + 1 + indices[k + 10] = sj + 2 + indices[k + 11] = si + 2 + return {"points": points, "colors": colors, "indices": indices} + + +## Distance along unit ray [param u] to the chord from [param a] to [param b], +## both relative to the eye. Falls back to the nearer end when the ray runs +## parallel to the chord, which only happens for a degenerate pair. +static func _along(u: Vector2, a: Vector2, b: Vector2) -> float: + var ab := b - a + var denom := u.cross(ab) + if absf(denom) < 0.000001: + return minf(a.length(), b.length()) + return a.cross(b) / denom + + +## Distance from [param from] along unit [param dir] to the first tile that +## blocks sight, or [param max_dist] if there is none that close. +static func cast(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float) -> float: + return trace(map, from, dir, max_dist, 0.0, 0.0).x + + +## Follow one ray: where it hits, and how far its light carries into the wall. +## Returns (hit, fade_start, fade_end), all distances along the ray. +## +## Grid traversal (Amanatides and Woo), so it visits exactly the tiles the ray +## passes through and lands on the true edge of the one that stops it -- which +## is the whole difference from the old fog. A ray passing exactly through a +## corner shared by two blocking tiles is stopped there: two walls touching at +## a point leave no gap to see through. +## +## Past the hit, the light runs on to [param depth] measured straight in from the +## face it entered -- so along the ray that is depth / cos(angle of incidence), +## long for a grazing ray and short for a head-on one -- and the last +## [param feather] of it fades. It stops early, wherever it is, the moment the +## ray steps back out into a tile that does not block sight. +static func trace(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float, + depth: float, feather: float) -> Vector3: + var tile := MapGrid.TILE + var px := (from.x - map.origin.x) / tile + var py := (from.y - map.origin.y) / tile + var tx := floori(px) + var ty := floori(py) + var limit := max_dist / tile + var w := map.width + var h := map.height + var tiles := map.tiles + var sight: Array = MapGrid.BLOCKS_SIGHT + + var step_x := 0 + var step_y := 0 + var delta_x := INF + var delta_y := INF + var next_x := INF + var next_y := INF + if dir.x > 0.0: + step_x = 1 + delta_x = 1.0 / dir.x + next_x = (float(tx + 1) - px) * delta_x + elif dir.x < 0.0: + step_x = -1 + delta_x = -1.0 / dir.x + next_x = (px - float(tx)) * delta_x + if dir.y > 0.0: + step_y = 1 + delta_y = 1.0 / dir.y + next_y = (float(ty + 1) - py) * delta_y + elif dir.y < 0.0: + step_y = -1 + delta_y = -1.0 / dir.y + next_y = (py - float(ty)) * delta_y + + # Phase 1 walks open ground to the first blocker; phase 2 walks on through + # the blocker while there is light left to carry. + var hit := -1.0 + var stop := limit + var fade := limit + while true: + var t: float + var crossed_x := false + var diagonal := false + if next_x < next_y - 0.000000001: + t = next_x + tx += step_x + next_x += delta_x + crossed_x = true + elif next_y < next_x - 0.000000001: + t = next_y + ty += step_y + next_y += delta_y + else: + t = next_x + diagonal = true + if t > stop: + break + if diagonal: + var side_x := tx + step_x + var side_y := ty + step_y + var across_x := side_x < 0 or ty < 0 or side_x >= w or ty >= h \ + or bool(sight[tiles[ty * w + side_x]]) + var across_y := tx < 0 or side_y < 0 or tx >= w or side_y >= h \ + or bool(sight[tiles[side_y * w + tx]]) + tx = side_x + ty = side_y + next_x += delta_x + next_y += delta_y + crossed_x = true + if hit < 0.0: + if across_x and across_y: + hit = t + stop = minf(limit, t + depth / tile / maxf(absf(dir.x), absf(dir.y))) + fade = minf(stop, t + (depth - feather) / tile / maxf(absf(dir.x), absf(dir.y))) + continue + elif not (across_x and across_y): + stop = t + break + # Off the map reads as wall, the same rule MapGrid.at() keeps. + var blocked := tx < 0 or ty < 0 or tx >= w or ty >= h \ + or bool(sight[tiles[ty * w + tx]]) + if hit < 0.0: + if blocked: + hit = t + # Incidence against the face just crossed: an x step crosses a + # vertical face, whose normal is along x. + var c := maxf(absf(dir.x) if crossed_x else absf(dir.y), 0.000001) + stop = minf(limit, t + depth / tile / c) + fade = minf(stop, t + (depth - feather) / tile / c) + elif not blocked: + # Back out into the open: the light ends at this face, wherever the + # depth had reached. This is what keeps it from rounding a corner. + stop = t + break + if hit < 0.0: + return Vector3(max_dist, max_dist, max_dist) + stop = minf(stop, limit) + fade = minf(fade, stop) + return Vector3(hit * tile, fade * tile, stop * tile) + + +## Rebuild the corner list if the map changed or the eye moved to another tile. +## +## A corner is any tile vertex where blocking and open ground meet other than +## along a straight edge: one or three of the four surrounding tiles blocking, or +## two blocking diagonally. Those are the only points a shadow edge can leave +## from, which is why aiming rays at them is enough to make every edge exact. +func _refresh_corners(map: MapGrid) -> void: + var t := map.to_tile(eye) + var id := map.get_instance_id() + if id == _corner_map and map.revision == _corner_revision \ + and t == _corner_tile and radius == _corner_radius: + return + _corner_map = id + _corner_revision = map.revision + _corner_tile = t + _corner_radius = radius + _corners.clear() + + var span := ceili(radius / MapGrid.TILE) + 2 + var x0 := maxi(t.x - span, 0) + var x1 := mini(t.x + span + 1, map.width) + var y0 := maxi(t.y - span, 0) + var y1 := mini(t.y + span + 1, map.height) + for vy in range(y0, y1 + 1): + for vx in range(x0, x1 + 1): + var a := map.blocks_sight(vx - 1, vy - 1) + var b := map.blocks_sight(vx, vy - 1) + var c := map.blocks_sight(vx - 1, vy) + var d := map.blocks_sight(vx, vy) + var count := int(a) + int(b) + int(c) + int(d) + # Two blocking and a == d means the pair is diagonal (a+d or b+c). + if count == 1 or count == 3 or (count == 2 and a == d): + _corners.append(map.origin + Vector2(float(vx), float(vy)) * MapGrid.TILE) diff --git a/src/view/visibility.gd.uid b/src/view/visibility.gd.uid new file mode 100644 index 0000000..9ab259d --- /dev/null +++ b/src/view/visibility.gd.uid @@ -0,0 +1 @@ +uid://bikom644gux4e diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 91d84dd..3c887e9 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -33,6 +33,16 @@ var client: ClientRuntime = null var _anim_time: float = 0.0 ## F1 overlay: draws what the simulation collides against, over what is drawn. var debug_hitboxes: bool = false +## What the local player can see this frame. See [Visibility]. +var _sight := Visibility.new() +## The darkness is the window's clear colour, exactly, so where it is complete it +## is indistinguishable from the undrawn void beyond it and there is no seam. +var _dark: Color = ProjectSettings.get_setting( + "rendering/environment/defaults/default_clear_color", Color.BLACK) +## How far past the edge of sight the darkness is drawn. It has to cover every +## tile and sprite that is drawn at all -- a tile touching the circle of sight +## reaches 45 units past it, a boss sprite further. +const DARK_REACH := 128.0 func _unhandled_input(event: InputEvent) -> void: @@ -50,28 +60,56 @@ func _process(delta: float) -> void: queue_redraw() +## Draw order is the fog's whole mechanism, so it is worth spelling out. +## +## Everything that fog may hide is drawn first, unconditionally within sight +## range; then the darkness goes over it. That is what lets a tile -- or an enemy +## stepping round a corner -- be half lit: nothing is decided per object, the +## shadow simply covers whatever is behind it. Telegraphs go on top of the +## darkness because a warning you cannot see is no warning (see +## _draw_telegraphs). The local player goes on top because it is the eye. And +## bullets are a child node, so they are drawn after all of this, over the dark +## -- as they always were: a shot coming out of the black is a shot you can +## still dodge. func _draw() -> void: if client == null: return + var map: MapGrid = client.world.map + if map != null: + _sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS) _draw_terrain() _draw_portals() _draw_upgrade_npc() for l in client.ground_loot(): - if _visible(l["pos"]): + if _in_view(l["pos"]): _draw_loot(l) for e in client.enemies(): - if _visible(e["pos"]): + if _in_view(e["pos"]): _draw_enemy(e) _draw_boss() - _draw_telegraphs() for p in client.remote_players(): - if _visible(p["pos"]): + if _in_view(p["pos"]): _draw_remote_player(p) + if map != null: + _draw_darkness() + _draw_telegraphs() _draw_local_player() if debug_hitboxes: _draw_debug() +## Everything the player cannot see, as one batch of triangles. +## +## Straight to the RenderingServer rather than through draw_polygon: a CanvasItem +## draw call is a thin wrapper around exactly this, but it takes one polygon, +## and the shadow is several hundred. Issued from inside _draw on this item's own +## canvas, so it lands in order between what was drawn before and after it. +func _draw_darkness() -> void: + var mesh := _sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark) + RenderingServer.canvas_item_add_triangle_array(get_canvas_item(), + mesh["indices"], mesh["points"], mesh["colors"]) + + ## Overlay showing what actually collides, on top of what is drawn. Deliberately ## ignores fog: the point is to check that art and simulation line up, and ## hiding half the evidence would defeat that. @@ -91,6 +129,7 @@ func _draw_debug() -> void: DebugDraw.draw_player(self, p["pos"], p["aim"]) DebugDraw.draw_player(self, client.predicted_pos, client.aim) DebugDraw.draw_ranges(self, client.predicted_pos) + DebugDraw.draw_sight(self, _sight) ## Aggro radius by visual index. Read from the content definitions rather than @@ -103,15 +142,16 @@ func _aggro_for(visual: int) -> float: return 0.0 -## Terrain, drawn only where the player can currently see it. +## Terrain within sight range, all of it. Whether it can actually be SEEN is +## decided afterwards, by the darkness drawn over it -- see _draw. ## -## 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. +## Still hard fog: nothing is remembered, and ground you walked over is dark +## again once it is behind you. Note what this is and is not -- 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: @@ -121,15 +161,18 @@ func _draw_terrain() -> void: var lo := map.to_tile(eye - Vector2(view, view)) var hi := map.to_tile(eye + Vector2(view, view)) var view_sq := view * view + var half := Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5 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): + # The tile's nearest point, not its centre: a tile the circle of + # sight cuts through is partly inside it, and skipping it would put + # a notch in the edge. + var near := eye.clamp(centre - half, centre + half) + if eye.distance_squared_to(near) > view_sq: continue _draw_tile(kind, centre, tx, ty) @@ -158,16 +201,14 @@ func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void: draw_texture_rect_region(Art.TILESET, dest, Art.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) +## Whether an actor is near enough to be worth drawing. Distance only: whether +## it is behind a wall is the darkness's job, not this one's, and that is what +## lets something stepping round a corner be half visible instead of popping in +## whole the moment its centre clears the edge. The margin lets a large sprite +## standing just past the circle of sight show the part of it that is inside. +func _in_view(at: Vector2) -> bool: + var reach := SimConfig.FOG_VIEW_RADIUS + 48.0 + return client.predicted_pos.distance_squared_to(at) <= reach * reach ## One ring per entrance, coloured and labelled by the dungeon it opens. The @@ -192,7 +233,7 @@ func _draw_upgrade_npc() -> void: if client.instance_kind != Protocol.InstanceKind.LOBBY: return var at := client.upgrade_npc - if not _visible(at): + if not _in_view(at): return var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3)) _draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR) @@ -256,8 +297,8 @@ func _draw_enemy(e: Dictionary) -> void: ## Attacks that have been announced but not yet landed. ## -## Drawn through fog and through walls, deliberately. Everything else in this -## view respects line of sight, but a warning you cannot see is not a warning -- +## Drawn over the darkness and through walls, deliberately. Everything else in +## this view is covered by it, but a warning you cannot see is not a warning -- ## it is an unavoidable hit with extra steps, and the entire reason telegraphs ## exist is to make the strike a decision. The marker is inside the boss arena ## you are already standing in, so this hides nothing a player could not walk @@ -278,7 +319,7 @@ func _draw_telegraphs() -> void: func _draw_boss() -> void: var b := client.boss_state() - if b.is_empty() or not _visible(b["pos"]): + if b.is_empty() or not _in_view(b["pos"]): return var pos: Vector2 = b["pos"] var visual := client.boss_def.visual if client.boss_def != null else 0 diff --git a/tests/unit/test_visibility.gd b/tests/unit/test_visibility.gd new file mode 100644 index 0000000..1efd2fc --- /dev/null +++ b/tests/unit/test_visibility.gd @@ -0,0 +1,305 @@ +extends GutTest +## The fog's geometry. Nothing here can look at the screen -- screenshot.tscn is +## for that -- but every property that makes the fog fair or unfair is a +## question about which points are lit, and those can be asked directly. + +const T := MapGrid.TILE +const VIEW := 460.0 + +var sight: Visibility + + +func before_each() -> void: + sight = Visibility.new() + + +## An open floor, walled at the border, with the origin at a tile corner so +## world positions are easy to reason about. +func _room(w: int = 40, h: int = 40) -> MapGrid: + var g := MapGrid.new(w, h, MapGrid.Kind.WALL) + g.fill_rect(Rect2i(1, 1, w - 2, h - 2), MapGrid.Kind.FLOOR) + return g + + +func _centre(tx: int, ty: int) -> Vector2: + return Vector2(float(tx) + 0.5, float(ty) + 0.5) * T + + +func test_open_ground_is_lit_and_beyond_sight_is_not() -> void: + var g := _room() + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + assert_eq(sight.light_at(eye + Vector2(100.0, 0.0)), 1.0) + assert_eq(sight.light_at(eye + Vector2(0.0, -(VIEW - Visibility.FADE - 1.0))), 1.0, + "lit all the way to where the fade begins") + assert_eq(sight.light_at(eye + Vector2(VIEW + 5.0, 0.0)), 0.0, + "nothing past the radius") + var mid := sight.light_at(eye + Vector2(VIEW - Visibility.FADE * 0.5, 0.0)) + assert_between(mid, 0.2, 0.8, "the edge of sight fades instead of cutting") + + +func test_a_wall_hides_what_is_behind_it() -> void: + var g := _room() + for ty in range(1, 39): + g.set_tile(24, ty, MapGrid.Kind.WALL) + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + assert_eq(sight.light_at(_centre(28, 20)), 0.0) + assert_eq(sight.light_at(_centre(22, 20)), 1.0, "and not what is in front") + + +## The feature asked for: a shadow edge that runs THROUGH a tile, so part of it +## is lit and part is not. The old fog could only light or hide a tile whole. +func test_one_tile_can_be_part_lit() -> void: + var g := _room() + g.set_tile(22, 20, MapGrid.Kind.PILLAR) + var eye := _centre(20, 20) + Vector2(0.0, 7.0) + sight.compute(g, eye, VIEW) + # The pillar's shadow is the wedge between its top-left and bottom-left + # corners as seen from the eye: -25.6 to +10.6 degrees. Tile (25, 21) spans + # about +2.9 to +15.9, so the lower edge of the wedge cuts straight across + # it. Sample a grid inside it and require both answers. + var lit := 0 + var dark := 0 + for sy in 8: + for sx in 8: + var p := Vector2(25.0 * T + 2.0 + float(sx) * 4.0, 21.0 * T + 2.0 + float(sy) * 4.0) + var l := sight.light_at(p) + if l >= 0.99: + lit += 1 + elif l <= 0.01: + dark += 1 + assert_gt(lit, 0, "some of the tile is in the light") + assert_gt(dark, 0, "and some of the same tile is in shadow") + + +func test_a_shadow_leaves_a_corner_as_a_straight_line() -> void: + var g := _room() + g.set_tile(22, 18, MapGrid.Kind.PILLAR) + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + # The pillar's lower-right corner, and the line from the eye through it. + # orthogonal() turns towards the pillar's other silhouette corner, which is + # the shadowed side; its negation points away from the pillar, into light. + var corner := Vector2(23.0, 19.0) * T + var away := (corner - eye).normalized() + var side := -away.orthogonal() + for dist in [140.0, 220.0, 300.0]: + var on_line: Vector2 = eye + away * dist + assert_eq(sight.light_at(on_line + side * 3.0), 1.0, + "%d units out, just on the open side of the edge" % dist) + assert_eq(sight.light_at(on_line - side * 3.0), 0.0, + "%d units out, just on the shadowed side" % dist) + + +## "A bit less strict": the face of the wall in front of you is lit, rather than +## being exactly where the dark begins and so never drawn at all. +func test_the_wall_you_are_facing_is_visible() -> void: + var g := _room() + for ty in range(1, 39): + g.set_tile(24, ty, MapGrid.Kind.WALL) + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + var face := 24.0 * T + # A fixed depth, not one computed from the constant: a test that samples at + # WALL_DEPTH - 2 passes with the depth at zero, sampling the floor in front. + assert_eq(sight.light_at(Vector2(face + 12.0, eye.y)), 1.0, + "the first stretch into the wall is fully lit") + assert_lt(sight.light_at(Vector2(face + Visibility.WALL_DEPTH + 1.0, eye.y)), + 0.01, "and it is dark again before the far side") + + +## The regression the first version of this shipped with, caught by a +## screenshot: light carried a fixed distance ALONG each ray, so a wall seen at a +## grazing angle -- the one right beside you in a tunnel -- got a sliver and +## looked missing. Depth is measured straight in from the face now. +func test_a_wall_you_are_looking_along_is_lit_its_whole_length() -> void: + var g := _room() + for ty in range(1, 39): + g.set_tile(24, ty, MapGrid.Kind.WALL) + var eye := Vector2(24.0 * T - 40.0, 20.5 * T) # just over a tile from the face + sight.compute(g, eye, VIEW) + var dim := 0 + for along in [0.0, 60.0, 120.0, 200.0, 280.0, 350.0]: + for side in [-1.0, 1.0]: + var p := Vector2(24.0 * T + 10.0, eye.y + along * side) + if sight.light_at(p) < 0.99: + dim += 1 + assert_eq(dim, 0, "ten units into the wall, all the way along it, is lit") + + +func test_light_does_not_round_a_corner() -> void: + # A wall ending in a corner, seen at a grazing angle. A ray that enters the + # wall near its end would, if its light ran on regardless, come out through + # the end face into the open space round the corner. It must stop there. + var g := _room() + for ty in range(1, 21): + g.set_tile(24, ty, MapGrid.Kind.WALL) # ends at row 20 + var eye := Vector2(24.0 * T - 40.0, 6.0 * T) + sight.compute(g, eye, VIEW) + # Round the end of the wall: past its far face, below its last tile. + for p in [Vector2(25.0 * T + 6.0, 21.0 * T + 6.0), + Vector2(26.0 * T, 21.0 * T + 20.0), + Vector2(25.0 * T + 20.0, 22.0 * T)]: + assert_eq(sight.light_at(p), 0.0, "round the corner at %s is dark" % p) + + +func test_light_never_reaches_the_far_side_of_a_one_tile_wall() -> void: + assert_lt(Visibility.WALL_DEPTH, T, + "the lit band inside a wall must stop inside a single tile of it") + var g := _room() + for ty in range(1, 39): + g.set_tile(24, ty, MapGrid.Kind.WALL) + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + # Just past the wall, at every height the eye can see the wall at -- steep + # and grazing alike. + var leaks := 0 + for ty in range(2, 38): + for fx in [0.5, 4.0, 12.0]: + if sight.light_at(Vector2(25.0 * T + fx, float(ty) * T + 16.0)) > 0.0: + leaks += 1 + assert_eq(leaks, 0, "nothing beyond a one-tile wall may be lit") + + +func test_a_staircase_wall_hides_its_far_side() -> void: + # A wall one tile thick that runs diagonally, as steps. A band of light + # measured straight in from the face it entered could leave a step through + # its other side -- which is the far side of the wall. + var g := _room() + for i in 14: + g.set_tile(18 + i, 6 + i, MapGrid.Kind.WALL) + g.set_tile(19 + i, 6 + i, MapGrid.Kind.WALL) + var eye := _centre(16, 16) # below-left of the stairs + sight.compute(g, eye, VIEW) + var leaks := 0 + for i in range(1, 12): + # The tile immediately above-right of each step: the far side. + var far := _centre(20 + i, 5 + i) + if g.at(20 + i, 5 + i) == MapGrid.Kind.FLOOR and sight.light_at(far) > 0.0: + leaks += 1 + assert_eq(leaks, 0, "the far side of a stepped wall stays dark") + + +func test_two_tiles_touching_at_a_corner_leave_no_gap_to_see_through() -> void: + var g := _room() + g.set_tile(22, 21, MapGrid.Kind.PILLAR) + g.set_tile(21, 22, MapGrid.Kind.PILLAR) + var eye := _centre(20, 20) + Vector2(16.0, 16.0) # the shared corner's diagonal + sight.compute(g, eye, VIEW) + # Straight along the diagonal, through the point where the two pillars meet. + var through := eye + Vector2(1.0, 1.0).normalized() * 120.0 + assert_eq(sight.light_at(through), 0.0, + "two walls meeting at a point are closed, not a crack") + # Vector2 is single precision and the cast is double, so "exactly" is to a + # thousandth of a unit. + assert_almost_eq(Visibility.cast(g, eye, Vector2(1.0, 1.0).normalized(), VIEW), + Vector2(22.0 * T, 22.0 * T).distance_to(eye), 0.001, + "the ray stops exactly at the shared corner") + + +func test_a_ray_stops_exactly_on_the_face_it_hits() -> void: + var g := _room() + g.set_tile(25, 20, MapGrid.Kind.WALL) + var eye := _centre(20, 20) + assert_almost_eq(Visibility.cast(g, eye, Vector2.RIGHT, VIEW), 25.0 * T - eye.x, 0.0001) + assert_eq(Visibility.cast(g, eye, Vector2.UP, 100.0), 100.0, + "nothing within reach means the full reach") + + +func test_only_what_blocks_sight_casts_a_shadow() -> void: + var g := _room() + var eye := _centre(20, 20) + for kind in [MapGrid.Kind.PIT, MapGrid.Kind.BARRICADE]: + g.set_tile(23, 20, kind) + sight.compute(g, eye, VIEW) + assert_eq(sight.light_at(_centre(26, 20)), 1.0, + "you can see across a %s" % MapGrid.Kind.keys()[kind]) + g.set_tile(23, 20, MapGrid.Kind.PILLAR) + sight.compute(g, eye, VIEW) + assert_eq(sight.light_at(_centre(26, 20)), 0.0, "but not past a pillar") + + +func test_a_streamed_wall_is_noticed() -> void: + # The corner list is cached between frames, so a chunk arriving with a new + # wall in it has to invalidate it. A stale list does not lose the shadow -- + # rays read the tiles live -- it loses the EDGE: the new pillar's corners get + # no rays of their own, and its silhouette is drawn only as finely as the + # sweep, two degrees at a time. So the check is the edge, not the shadow. + var g := _room() + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + g.set_tile(22, 18, MapGrid.Kind.PILLAR) + sight.compute(g, eye, VIEW) + var corner := Vector2(23.0, 19.0) * T + var away := (corner - eye).normalized() + var side := -away.orthogonal() + var on_line := eye + away * 300.0 + assert_eq(sight.light_at(on_line + side * 2.0), 1.0, + "the new pillar's edge is as sharp as one that was always there") + assert_eq(sight.light_at(on_line - side * 2.0), 0.0) + + +func test_light_falls_off_monotonically_along_every_ray() -> void: + var g := _room() + g.set_tile(23, 18, MapGrid.Kind.PILLAR) + g.set_tile(17, 23, MapGrid.Kind.WALL) + var eye := _centre(20, 20) + sight.compute(g, eye, VIEW) + for i in 64: + var dir := Vector2.from_angle(TAU * float(i) / 64.0) + var last := 1.0 + var r := 4.0 + while r < VIEW + 20.0: + var l := sight.light_at(eye + dir * r) + assert_lte(l, last + 0.0001, "light never comes back along a ray") + last = l + r += 6.0 + + +func test_the_shadow_mesh_is_well_formed() -> void: + var g := _room() + g.set_tile(23, 18, MapGrid.Kind.PILLAR) + sight.compute(g, _centre(20, 20), VIEW) + var mesh := sight.shadow_mesh(VIEW + 64.0, Color(0.05, 0.05, 0.08)) + var points: PackedVector2Array = mesh["points"] + var colors: PackedColorArray = mesh["colors"] + var indices: PackedInt32Array = mesh["indices"] + assert_eq(points.size(), sight.angles.size() * 3) + assert_eq(colors.size(), points.size(), "a colour per vertex") + assert_eq(indices.size() % 3, 0, "whole triangles") + for k in indices: + assert_between(k, 0, points.size() - 1) + for c in colors: + assert_true(c.a == 0.0 or c.a == 1.0, "clear at the fade's start, dark beyond") + + +func test_neighbouring_rays_are_never_far_apart() -> void: + # The outline between two rays is a straight chord. If the gap were wide, the + # chord would cut inside the circle of sight on open ground, and a wedge of + # darkness would appear where nothing is in the way. + var g := _room() + sight.compute(g, _centre(20, 20), VIEW) + var widest := 0.0 + var n := sight.angles.size() + for i in n: + var gap := wrapf(sight.angles[(i + 1) % n] - sight.angles[i], 0.0, TAU) + widest = maxf(widest, gap) + var dip := (VIEW - Visibility.FADE) * (1.0 - cos(widest * 0.5)) + assert_lt(dip, 1.0, + "the chord dips %.2f units into open ground; under a pixel is invisible" % dip) + # And the behaviour that follows from it: halfway between two rays, right at + # the start of the fade, open ground is still fully lit. + var mid := (sight.angles[0] + sight.angles[1]) * 0.5 + assert_eq(sight.light_at(_centre(20, 20) + + Vector2.from_angle(mid) * (VIEW - Visibility.FADE - 1.0)), 1.0) + + +func test_a_real_dungeon_is_cheap_enough_to_do_every_frame() -> void: + # Not a benchmark -- the machine running the suite varies -- but a guard + # against the ray count exploding: corners are the variable part, and a + # map with thousands of them in view would be a regression worth knowing. + var inst := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD) + sight.compute(inst.world.map, inst.world.spawn_point, VIEW) + assert_lt(sight.angles.size(), 1200, + "%d rays for one frame of a depth-1 dungeon" % sight.angles.size()) diff --git a/tests/unit/test_visibility.gd.uid b/tests/unit/test_visibility.gd.uid new file mode 100644 index 0000000..731840a --- /dev/null +++ b/tests/unit/test_visibility.gd.uid @@ -0,0 +1 @@ +uid://beivpa4pxiu3f diff --git a/tools/screenshot.gd b/tools/screenshot.gd index c7e16a8..2497862 100644 --- a/tools/screenshot.gd +++ b/tools/screenshot.gd @@ -29,7 +29,12 @@ const OPEN_UPGRADES := 330 const AT_UPGRADES := 360 const OPEN_ROSTER := 370 const AT_ROSTER := 400 -const DONE := 410 +const ENTER_DUNGEON := 410 +const AT_DUNGEON := 480 +const TO_TUNNEL := 490 +const AT_TUNNEL := 550 +const AT_TUNNEL_DEBUG := 560 +const DONE := 570 var _frame: int = 0 var _game: Node = null @@ -44,6 +49,10 @@ func _ready() -> void: var from_env := OS.get_environment("SHOT_DIR") if not from_env.is_empty(): _dir = from_env + # Made rather than assumed. The example in CLAUDE.md points at a fresh + # /tmp/shots, and a missing directory fails every capture one at a time + # with an error that names the file rather than the folder. + DirAccess.make_dir_recursive_absolute(_dir) get_window().size = Vector2i(1280, 720) _menu = preload("res://src/ui/main_menu.gd").new() add_child(_menu) @@ -118,6 +127,23 @@ func _process(_delta: float) -> void: _show_the_roster() AT_ROSTER: _grab("roster") + ENTER_DUNGEON: + # The fog is what these two are for, and the hub is the worst place to + # judge it: one big room with little in it to cast a shadow. + _game.set("_roster_open", false) + Net.server._send_to_dungeon(Net.LOCAL_PEER) + AT_DUNGEON: + _grab("dungeon") + TO_TUNNEL: + _stand_in_a_tunnel() + AT_TUNNEL: + _grab("tunnel") + # The same view with F1's overlay: the outline is where each fog ray + # stopped, which is the quickest way to tell a rendering mistake from + # a geometry one. + _game.get_node("WorldView").debug_hitboxes = true + AT_TUNNEL_DEBUG: + _grab("tunnel_debug") DONE: Net.shutdown() get_tree().quit(0) @@ -152,6 +178,46 @@ func _show_the_roster() -> void: _game.get_node("CharacterSelect").refresh(roster, "c4") +## Move the player, on both sides, into the tunnel closest to where it spawned: +## somewhere with walls close on two sides and a corner in view, which is where +## the old fog looked worst. +func _stand_in_a_tunnel() -> void: + var inst := Net.server.instance_of(Net.LOCAL_PEER) + if inst == null or inst.rooms.is_empty(): + return + var map := inst.world.map + var start: Rect2i = inst.rooms[0] + var spawn := map.to_tile(inst.world.spawn_point) + var best := Vector2i(-1, -1) + var best_d := INF + for ty in map.height: + for tx in map.width: + if map.at(tx, ty) != MapGrid.Kind.FLOOR: + continue + var here := Vector2i(tx, ty) + var in_room := inst.boss_room.has_point(here) + for r: Rect2i in inst.rooms: + if r.grow(1).has_point(here): + in_room = true + if in_room: + continue + # A couple of tiles clear of the first room, so the tunnel's walls + # are on screen and not just the doorway. + if start.grow(3).has_point(here): + continue + var d := float((here - spawn).length_squared()) + if d < best_d: + best_d = d + best = here + if best.x < 0: + return + var at := map.tile_centre(best.x, best.y) + Net.client.predicted_pos = at + var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER) + if p != null: + p.pos = at + + ## One roster row in the shape decode_characters produces. func _roster_entry(who: String, level: int, ceiling: int, current: int, tint: Color) -> Dictionary: