Compare commits
2
Commits
a6084c1a8d
...
855669a665
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
855669a665 | ||
|
|
83c3b920fb |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bikom644gux4e
|
||||||
+70
-29
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://beivpa4pxiu3f
|
||||||
+67
-1
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user