Compare commits

..
2 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 855669a665 Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
The fog 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, tiles
snapped in and out as you walked, and a wall beside you vanished whenever
its centre happened to sit behind its neighbour.

Visibility casts rays from the eye instead: a fixed sweep, plus two aimed
either side of every wall corner, which is what makes each shadow edge a
straight line rather than a smear between rays. The view now draws terrain
and actors first and then darkness over whatever the rays did not reach,
so a shadow runs straight through a tile and an enemy rounding a corner
appears a slice at a time. Bullets are a child node and are still drawn
over the dark; telegraphs are drawn after it, as they must be.

"A bit less strict" is three specific things. A wall face you can see is
lit 26 units in, measured perpendicular to the face; shadows end in a
short fade rather than a line; and sight fades over its last 64 units
rather than stopping at a circle. Still hard fog -- nothing remembered.

The first version measured that depth along the ray, and a screenshot
caught what no test had: a wall you look along got a sliver, so the wall
beside you in a tunnel looked missing -- the old fog's own complaint.
Perpendicular depth fixes it, and light stops the instant a ray steps
back into the open, so it never rounds a corner, never reaches the far
side of a one-tile wall, and never leaves a stepped wall through its
other face. Two tiles touching only at a corner are closed.

About half a millisecond a frame. Measured against the old fog in the
same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's
own low rate predates this.

MapGrid gains a revision counter so the corner list is rebuilt only when
the map changes or the eye crosses a tile. screenshot.tscn now captures a
dungeon, a tunnel and the tunnel with F1's overlay, which draws where
each ray stopped -- and makes SHOT_DIR rather than failing every capture
when it does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:33 +02:00
claudeandClaude Opus 5.5 83c3b920fb Varied-width tunnels, and stop carving slits in the boss arenas
Every corridor was one tile wide, which in a bullet hell is a shooting
gallery: there is no side to step to, so anything fired down it lands.
Each leg of each L now rolls its own width between 2 and 4, so a tunnel
can open up or pinch at its bend rather than every connection being the
same pipe. Carving stops one tile short of the map's outer ring, which
keeps the world sealed however close to the edge a wide tunnel runs.

Found while doing it: 45% of generated arenas had their own walls carved
open. The last corridor aimed at the door tile, which sits ON the
arena's west wall, and an L that bent the other way ran its second leg
straight down that column -- 181 of 398 arenas, measured. It now aims at
the tile outside the door and always arrives along the door's own row,
and the arena is excluded from every carve, so a wide tunnel stops at
the wall instead of eating into it.

Three tests: no corridor tile sits in a passage one tile wide, every
width in the range turns up and none falls below two, and every arena
keeps every wall tile it was drawn with. The overshoot at a bend is
cosmetic -- the legs always overlap on the inner side, so removing it
bevels the outer corner and narrows nothing -- and the comment now says
so rather than claiming otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:51:19 +02:00
14 changed files with 1028 additions and 64 deletions

No files matched your search

+8 -3
View File
@@ -13,7 +13,7 @@ the obvious default.
```bash ```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit 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/smoke.sh # real server + 4 bot clients over ENet (~40s)
tools/server.sh # dedicated server tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu 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 . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas 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 `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 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 long time, which is invisible while bosses stand still and looks broken the
moment one moves. 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` - **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. warning you cannot see is an unavoidable hit with extra steps.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is - **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 the only invulnerable state. Do not reintroduce post-hit immunity — it makes
+6 -3
View File
@@ -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. 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)): 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 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 for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre
genre convention anyway. 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 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 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 | | 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/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/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. | | `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
+22
View File
@@ -89,6 +89,28 @@ about as often as a good one.
**Hard fog.** *(User, this session.)* No remembered terrain — anything outside **Hard fog.** *(User, this session.)* No remembered terrain — anything outside
current line of sight is not drawn, including ground already walked over. 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 **Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
flag; what raises depth in actual play is still open. flag; what raises depth in actual play is still open.
+10 -7
View File
@@ -40,11 +40,14 @@ and more specific than the table above.
### Known flake ### Known flake
`tools/smoke.sh` failed once in a pre-push hook and then passed on four `tools/smoke.sh` has failed twice, **both times inside the pre-push hook**, and
consecutive runs with no stray processes. It was not reproduced and is not then passed on every direct run after: four in a row the first time, eight the
diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets second (six in a background loop plus two more, one of them the hook itself).
and was competing with a full suite run — but that is a guess. If it recurs, Neither failure's logs survived, because the hook runs smoke without `KEEP`.
that is the first thing to check. 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 | | Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` | | Enemies placed per room at creation | done | `Instance._populate()` |
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | | 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` | | Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` | | Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` | | 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 - **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets. besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the - **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`. `WorldView._draw_telegraphs`.
### The Cantor of the Vault ### The Cantor of the Vault
+75 -19
View File
@@ -7,6 +7,16 @@ extends RefCounted
## reproducible from its log line. It does NOT mean the client can rebuild the ## reproducible from its log line. It does NOT mean the client can rebuild the
## map -- see the note on build(). ## map -- see the note on build().
## Tunnel widths, in tiles, rolled per leg of every corridor.
##
## The floor is 2 because a one-tile tunnel in a bullet hell is a shooting
## gallery: there is no side to step to, so anything fired down it lands. Each
## leg of an L rolls its own width, so a tunnel can open up or pinch at its
## bend rather than every connection in the map being the same pipe.
const CORRIDOR_MIN_WIDTH := 2
const CORRIDOR_MAX_WIDTH := 4
## The single entry point. SERVER-SIDE ONLY. ## The single entry point. SERVER-SIDE ONLY.
## ##
## This used to be described as "both sides call it with the same three ## This used to be described as "both sides call it with the same three
@@ -140,13 +150,24 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# Connect each room to the previous one, then the last to the arena door, # 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. # so every room is reachable by construction rather than by luck.
var widths := PackedInt32Array()
for i in range(1, rooms.size()): for i in range(1, rooms.size()):
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i])) var from := _centre_of(rooms[i - 1])
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
(from.x + from.y) % 2 == 0, boss_room))
if not rooms.is_empty(): if not rooms.is_empty():
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door) # Aimed at the tile OUTSIDE the door, and always arriving along the
# door's own row. Aiming at the door itself, an L that bent the other
# way ran its second leg straight down the arena's west wall -- 45% of
# generated arenas had a slit carved in an authored room. The arena is
# also kept out of every carve, so a wide tunnel stops at its wall
# rather than eating into it.
var outside := door + Vector2i(-1, 0)
widths.append_array(_carve_corridor(grid, rng,
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room))
# The door tile itself, and the tile outside it, must be floor. # 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, door.y, MapGrid.Kind.FLOOR)
grid.set_tile(door.x - 1, door.y, MapGrid.Kind.FLOOR) grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
_scatter_cover(grid, rooms, rng) _scatter_cover(grid, rooms, rng)
@@ -168,6 +189,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
"npc": Vector2.ZERO, "npc": Vector2.ZERO,
"boss_pos": boss_pos, "boss_pos": boss_pos,
"boss_room": boss_room, "boss_room": boss_room,
# What every corridor leg was carved at. Not used by anything that
# builds a world -- it is here so a test can check the widths actually
# vary, which the tiles alone cannot say once tunnels overlap.
"corridor_widths": widths,
# Which boss lives here follows from WHICH arena was stamped. Choosing # Which boss lives here follows from WHICH arena was stamped. Choosing
# it anywhere else would let a dungeon put the Cantor in the Warden's # it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there. # hall, where its patterns assume geometry that is not there.
@@ -181,23 +206,54 @@ static func _centre_of(r: Rect2i) -> Vector2i:
## L-shaped, with the corner order chosen by parity so corridors do not all ## L-shaped, with the corner order chosen by parity so corridors do not all
## bend the same way. ## bend the same way.
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void: ## An L-shaped tunnel from [param from] to [param to], each leg its own width.
if (from.x + from.y) % 2 == 0: ## Returns the two widths it rolled.
_carve_h(grid, from.x, to.x, from.y) ##
_carve_v(grid, from.y, to.y, to.x) ## [param horizontal_first] picks which way the L bends. [param keep_out] is
## never carved: it is the boss arena, which is authored, and keeps exactly the
## walls it was drawn with.
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
from: Vector2i, to: Vector2i, horizontal_first: bool,
keep_out: Rect2i) -> PackedInt32Array:
var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y)
# Each leg overshoots the bend by half the other's width, so the corner is
# carved square. Cosmetic, not structural: the two legs always overlap on
# the INNER side of the bend, so leaving this out never narrows a tunnel
# below its width -- it just bevels the outer corner, which reads as a
# notch rather than a turn.
_carve_leg(grid, from, bend, first, second / 2, keep_out)
_carve_leg(grid, bend, to, second, first / 2, keep_out)
return PackedInt32Array([first, second])
## One straight leg of [param width] tiles between two points sharing a row or
## a column, extended [param overshoot] tiles past both ends.
##
## Never touches the map's outermost ring: out-of-bounds reads as wall, and the
## world is sealed by construction, so a wide tunnel that hugs the edge must
## stop one tile short of it rather than open the map to nothing.
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
overshoot: int, keep_out: Rect2i) -> void:
# Width is laid across the leg, centred on its line: two tiles is the line
# and the one after it, three is one either side, four leans one way.
var lo := -(width - 1) / 2
var hi := width / 2
var rect: Rect2i
if a.y == b.y:
var x0 := mini(a.x, b.x) - overshoot
var x1 := maxi(a.x, b.x) + overshoot
rect = Rect2i(x0, a.y + lo, x1 - x0 + 1, hi - lo + 1)
else: else:
_carve_v(grid, from.y, to.y, from.x) var y0 := mini(a.y, b.y) - overshoot
_carve_h(grid, from.x, to.x, to.y) var y1 := maxi(a.y, b.y) + overshoot
rect = Rect2i(a.x + lo, y0, hi - lo + 1, y1 - y0 + 1)
for y in range(maxi(rect.position.y, 1), mini(rect.end.y, grid.height - 1)):
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void: for x in range(maxi(rect.position.x, 1), mini(rect.end.x, grid.width - 1)):
for x in range(mini(x0, x1), maxi(x0, x1) + 1): if keep_out.has_point(Vector2i(x, y)):
grid.set_tile(x, y, MapGrid.Kind.FLOOR) continue
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 ## A few pillars and pits inside generated rooms, so open rooms still have
+17 -2
View File
@@ -54,6 +54,11 @@ var tiles := PackedByteArray()
## existing coordinate (spawn points, portal, boss placement) meaningful and ## existing coordinate (spawn points, portal, boss placement) meaningful and
## avoids an all-positive coordinate space where "0" is a corner. ## avoids an all-positive coordinate space where "0" is a corner.
var origin := Vector2.ZERO 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: 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) height = maxi(h, 1)
tiles.resize(width * height) tiles.resize(width * height)
tiles.fill(fill) tiles.fill(fill)
revision += 1
func in_bounds(tx: int, ty: int) -> bool: 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: func set_tile(tx: int, ty: int, kind: Kind) -> void:
if in_bounds(tx, ty): if in_bounds(tx, ty):
tiles[ty * width + tx] = kind tiles[ty * width + tx] = kind
revision += 1
func fill_rect(rect: Rect2i, kind: Kind) -> void: 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. ## Centre the map on the world origin.
func centre_on_origin() -> void: func centre_on_origin() -> void:
origin = -world_size() * 0.5 origin = -world_size() * 0.5
revision += 1
## World-space rectangle the map occupies. ## World-space rectangle the map occupies.
@@ -175,8 +183,14 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
return out return out
## Bresenham-style sight test between two world points. Used for fog on the ## Bresenham-style sight test between two world points, from tile to tile.
## client and for aggro on the server, so it has to agree on both. ## 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 ## [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 ## 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): for tx in range(r.position.x, r.end.x):
tiles[ty * width + tx] = data[i] tiles[ty * width + tx] = data[i]
i += 1 i += 1
revision += 1
+16
View File
@@ -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_AGGRO := Color(1.0, 0.5, 0.2, 0.25)
const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9) 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_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. ## 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. ## 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: 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) 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)
+348
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bikom644gux4e
+70 -29
View File
@@ -33,6 +33,16 @@ var client: ClientRuntime = null
var _anim_time: float = 0.0 var _anim_time: float = 0.0
## F1 overlay: draws what the simulation collides against, over what is drawn. ## F1 overlay: draws what the simulation collides against, over what is drawn.
var debug_hitboxes: bool = false 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: func _unhandled_input(event: InputEvent) -> void:
@@ -50,28 +60,56 @@ func _process(delta: float) -> void:
queue_redraw() 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: func _draw() -> void:
if client == null: if client == null:
return return
var map: MapGrid = client.world.map
if map != null:
_sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
_draw_terrain() _draw_terrain()
_draw_portals() _draw_portals()
_draw_upgrade_npc() _draw_upgrade_npc()
for l in client.ground_loot(): for l in client.ground_loot():
if _visible(l["pos"]): if _in_view(l["pos"]):
_draw_loot(l) _draw_loot(l)
for e in client.enemies(): for e in client.enemies():
if _visible(e["pos"]): if _in_view(e["pos"]):
_draw_enemy(e) _draw_enemy(e)
_draw_boss() _draw_boss()
_draw_telegraphs()
for p in client.remote_players(): for p in client.remote_players():
if _visible(p["pos"]): if _in_view(p["pos"]):
_draw_remote_player(p) _draw_remote_player(p)
if map != null:
_draw_darkness()
_draw_telegraphs()
_draw_local_player() _draw_local_player()
if debug_hitboxes: if debug_hitboxes:
_draw_debug() _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 ## 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 ## ignores fog: the point is to check that art and simulation line up, and
## hiding half the evidence would defeat that. ## 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, p["pos"], p["aim"])
DebugDraw.draw_player(self, client.predicted_pos, client.aim) DebugDraw.draw_player(self, client.predicted_pos, client.aim)
DebugDraw.draw_ranges(self, client.predicted_pos) DebugDraw.draw_ranges(self, client.predicted_pos)
DebugDraw.draw_sight(self, _sight)
## Aggro radius by visual index. Read from the content definitions rather than ## 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 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 ## Still hard fog: nothing is remembered, and ground you walked over is dark
## walked over. Note what this is and is not -- it is a rendering rule, not a ## again once it is behind you. Note what this is and is not -- a rendering
## secrecy mechanism. The client holds real tile data because it simulates ## rule, not a secrecy mechanism. The client holds real tile data because it
## bullets, so a modified client could draw what is hidden here. What it cannot ## simulates bullets, so a modified client could draw what is hidden here. What
## do is see terrain the server never streamed (only a radius around the ## it cannot do is see terrain the server never streamed (only a radius around
## player) or actors the server filtered out, which is where the line actually ## the player) or actors the server filtered out, which is where the line
## sits. ## actually sits.
func _draw_terrain() -> void: func _draw_terrain() -> void:
var map: MapGrid = client.world.map var map: MapGrid = client.world.map
if map == null: if map == null:
@@ -121,15 +161,18 @@ func _draw_terrain() -> void:
var lo := map.to_tile(eye - Vector2(view, view)) var lo := map.to_tile(eye - Vector2(view, view))
var hi := map.to_tile(eye + Vector2(view, view)) var hi := map.to_tile(eye + Vector2(view, view))
var view_sq := 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 ty in range(lo.y, hi.y + 1):
for tx in range(lo.x, hi.x + 1): for tx in range(lo.x, hi.x + 1):
var kind := map.at(tx, ty) var kind := map.at(tx, ty)
if kind == MapGrid.Kind.UNKNOWN: if kind == MapGrid.Kind.UNKNOWN:
continue continue
var centre := map.tile_centre(tx, ty) var centre := map.tile_centre(tx, ty)
if eye.distance_squared_to(centre) > view_sq: # The tile's nearest point, not its centre: a tile the circle of
continue # sight cuts through is partly inside it, and skipping it would put
if not map.has_line_of_sight(eye, centre): # a notch in the edge.
var near := eye.clamp(centre - half, centre + half)
if eye.distance_squared_to(near) > view_sq:
continue continue
_draw_tile(kind, centre, tx, ty) _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) draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
## Actors are only drawn where the player can actually see them. The server ## Whether an actor is near enough to be worth drawing. Distance only: whether
## already withholds distant ones; this hides the ones behind walls. ## it is behind a wall is the darkness's job, not this one's, and that is what
func _visible(at: Vector2) -> bool: ## lets something stepping round a corner be half visible instead of popping in
var map: MapGrid = client.world.map ## whole the moment its centre clears the edge. The margin lets a large sprite
if map == null: ## standing just past the circle of sight show the part of it that is inside.
return true func _in_view(at: Vector2) -> bool:
var eye := client.predicted_pos var reach := SimConfig.FOG_VIEW_RADIUS + 48.0
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS: return client.predicted_pos.distance_squared_to(at) <= reach * reach
return false
return map.has_line_of_sight(eye, at)
## One ring per entrance, coloured and labelled by the dungeon it opens. The ## 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: if client.instance_kind != Protocol.InstanceKind.LOBBY:
return return
var at := client.upgrade_npc var at := client.upgrade_npc
if not _visible(at): if not _in_view(at):
return return
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3)) 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) _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. ## Attacks that have been announced but not yet landed.
## ##
## Drawn through fog and through walls, deliberately. Everything else in this ## Drawn over the darkness and through walls, deliberately. Everything else in
## view respects line of sight, but a warning you cannot see is not a warning -- ## 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 ## 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 ## 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 ## 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: func _draw_boss() -> void:
var b := client.boss_state() 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 return
var pos: Vector2 = b["pos"] var pos: Vector2 = b["pos"]
var visual := client.boss_def.visual if client.boss_def != null else 0 var visual := client.boss_def.visual if client.boss_def != null else 0
+82
View File
@@ -83,3 +83,85 @@ func test_rooms_do_not_overlap() -> void:
for j in range(i + 1, rooms.size()): for j in range(i + 1, rooms.size()):
assert_false((rooms[i] as Rect2i).intersects(rooms[j]), assert_false((rooms[i] as Rect2i).intersects(rooms[j]),
"rooms %d and %d overlap" % [i, j]) "rooms %d and %d overlap" % [i, j])
# --- Tunnels ----------------------------------------------------------------
func _inside_any(rooms: Array, x: int, y: int) -> bool:
for r: Rect2i in rooms:
if r.has_point(Vector2i(x, y)):
return true
return false
## Whether (x, y) is a corner of at least one 2x2 square of open floor. A
## passage one tile wide has no such square anywhere along it.
func _in_open_square(g: MapGrid, x: int, y: int) -> bool:
for dy in [-1, 0]:
for dx in [-1, 0]:
var ok := true
for oy in 2:
for ox in 2:
if g.at(x + dx + ox, y + dy + oy) != MapGrid.Kind.FLOOR:
ok = false
if ok:
return true
return false
func test_no_tunnel_is_a_single_file_squeeze() -> void:
# A one-tile corridor in a bullet hell is a shooting gallery: there is no
# side to step to. Every corridor tile has to sit in open ground at least
# two wide in some direction. Rooms and the authored arena are left out --
# the arena's door is drawn one tile wide on purpose.
for s in range(1, 40):
var m := MapGen.generate(s * 7919, 1 + (s % 5))
var g: MapGrid = m["grid"]
var rooms: Array = m["rooms"]
var arena: Rect2i = m["boss_room"]
var squeezes := 0
for y in g.height:
for x in g.width:
if g.at(x, y) != MapGrid.Kind.FLOOR:
continue
if _inside_any(rooms, x, y) or arena.has_point(Vector2i(x, y)):
continue
if not _in_open_square(g, x, y):
squeezes += 1
assert_eq(squeezes, 0,
"seed %d: %d corridor tile(s) in a passage one tile wide" % [s, squeezes])
func test_tunnel_widths_vary_and_never_go_below_two() -> void:
var seen := {}
for s in range(1, 40):
var widths: PackedInt32Array = MapGen.generate(s * 7919, 1 + (s % 5))["corridor_widths"]
assert_false(widths.is_empty(), "seed %d carved no tunnels at all" % s)
for w in widths:
assert_between(w, MapGen.CORRIDOR_MIN_WIDTH, MapGen.CORRIDOR_MAX_WIDTH)
seen[w] = true
assert_eq(seen.size(), MapGen.CORRIDOR_MAX_WIDTH - MapGen.CORRIDOR_MIN_WIDTH + 1,
"every width in the range turns up somewhere; got %s" % [seen.keys()])
func test_the_arena_keeps_every_wall_it_was_drawn_with() -> void:
# The corridor to the door used to aim at the door tile itself, which sits
# ON the arena's west wall. An L that bent the other way ran its second leg
# down that wall column, and 45% of generated arenas had a slit carved in
# an authored room. A wide tunnel makes the same mistake wider.
for arena in [&"warden_hall", &"choir_vault"]:
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
for s in range(1, 60):
var m := MapGen.generate(s * 7919, 1 + (s % 5), arena)
var g: MapGrid = m["grid"]
var room: Rect2i = m["boss_room"]
var carved := 0
for y in stamp.size():
for x in stamp[y].length():
if stamp[y][x] != "#":
continue
if g.at(room.position.x + x, room.position.y + y) != MapGrid.Kind.WALL:
carved += 1
assert_eq(carved, 0,
"seed %d %s: %d of the arena's own wall tiles were carved open"
% [s * 7919, arena, carved])
+305
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://beivpa4pxiu3f
+67 -1
View File
@@ -29,7 +29,12 @@ const OPEN_UPGRADES := 330
const AT_UPGRADES := 360 const AT_UPGRADES := 360
const OPEN_ROSTER := 370 const OPEN_ROSTER := 370
const AT_ROSTER := 400 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 _frame: int = 0
var _game: Node = null var _game: Node = null
@@ -44,6 +49,10 @@ func _ready() -> void:
var from_env := OS.get_environment("SHOT_DIR") var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty(): if not from_env.is_empty():
_dir = from_env _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) get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new() _menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu) add_child(_menu)
@@ -118,6 +127,23 @@ func _process(_delta: float) -> void:
_show_the_roster() _show_the_roster()
AT_ROSTER: AT_ROSTER:
_grab("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: DONE:
Net.shutdown() Net.shutdown()
get_tree().quit(0) get_tree().quit(0)
@@ -152,6 +178,46 @@ func _show_the_roster() -> void:
_game.get_node("CharacterSelect").refresh(roster, "c4") _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. ## One roster row in the shape decode_characters produces.
func _roster_entry(who: String, level: int, ceiling: int, current: int, func _roster_entry(who: String, level: int, ceiling: int, current: int,
tint: Color) -> Dictionary: tint: Color) -> Dictionary: