Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
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>
This commit is contained in:
1 parent
83c3b920fb
commit
855669a665
12 files changed
+871
-45
No files matched your search
+17
-2
@@ -54,6 +54,11 @@ var tiles := PackedByteArray()
|
||||
## existing coordinate (spawn points, portal, boss placement) meaningful and
|
||||
## avoids an all-positive coordinate space where "0" is a corner.
|
||||
var origin := Vector2.ZERO
|
||||
## Bumped by every write, so something that derives data from the tiles can tell
|
||||
## whether it is stale without comparing them. The client's fog extracts wall
|
||||
## corners from the map and would otherwise have to redo it every frame, or miss
|
||||
## a chunk arriving.
|
||||
var revision: int = 0
|
||||
|
||||
|
||||
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
|
||||
@@ -65,6 +70,7 @@ func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
|
||||
height = maxi(h, 1)
|
||||
tiles.resize(width * height)
|
||||
tiles.fill(fill)
|
||||
revision += 1
|
||||
|
||||
|
||||
func in_bounds(tx: int, ty: int) -> bool:
|
||||
@@ -82,6 +88,7 @@ func at(tx: int, ty: int) -> Kind:
|
||||
func set_tile(tx: int, ty: int, kind: Kind) -> void:
|
||||
if in_bounds(tx, ty):
|
||||
tiles[ty * width + tx] = kind
|
||||
revision += 1
|
||||
|
||||
|
||||
func fill_rect(rect: Rect2i, kind: Kind) -> void:
|
||||
@@ -111,6 +118,7 @@ func to_tile(world: Vector2) -> Vector2i:
|
||||
## Centre the map on the world origin.
|
||||
func centre_on_origin() -> void:
|
||||
origin = -world_size() * 0.5
|
||||
revision += 1
|
||||
|
||||
|
||||
## World-space rectangle the map occupies.
|
||||
@@ -175,8 +183,14 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
|
||||
return out
|
||||
|
||||
|
||||
## Bresenham-style sight test between two world points. Used for fog on the
|
||||
## client and for aggro on the server, so it has to agree on both.
|
||||
## Bresenham-style sight test between two world points, from tile to tile.
|
||||
## Used for aggro on the server.
|
||||
##
|
||||
## It used to drive the client's fog as well, which is why the two once had to
|
||||
## agree. The fog is exact geometry now (see Visibility), so this is the
|
||||
## server's question alone -- and it is coarser than what the player sees: its
|
||||
## diagonal steps pass between two tiles that touch only at a corner, which
|
||||
## the fog treats as closed.
|
||||
##
|
||||
## [param include_end] is false here: the endpoints themselves never block, so
|
||||
## standing in a doorway, or shooting at something embedded in a wall, still
|
||||
@@ -303,3 +317,4 @@ func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
|
||||
for tx in range(r.position.x, r.end.x):
|
||||
tiles[ty * width + tx] = data[i]
|
||||
i += 1
|
||||
revision += 1
|
||||
@@ -20,6 +20,7 @@ const COL_PIT := Color(0.4, 0.5, 1.0, 0.5)
|
||||
const COL_AGGRO := Color(1.0, 0.5, 0.2, 0.25)
|
||||
const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9)
|
||||
const COL_LOS := Color(0.5, 1.0, 0.8, 0.18)
|
||||
const COL_SIGHT := Color(1.0, 0.95, 0.55, 0.55) # where each fog ray stopped
|
||||
|
||||
|
||||
## Terrain: one outline per tile, coloured by which of the three flags it sets.
|
||||
@@ -82,3 +83,18 @@ static func draw_bullets(c: CanvasItem, pool: BulletPool) -> void:
|
||||
## A ring at the fog radius, so "why can I not see that" has a visible answer.
|
||||
static func draw_ranges(c: CanvasItem, pos: Vector2) -> void:
|
||||
c.draw_arc(pos, SimConfig.FOG_VIEW_RADIUS, 0.0, TAU, 64, COL_LOS, 1.0)
|
||||
|
||||
|
||||
## The outline the fog's rays traced: where each one stopped. Drawn as the hit
|
||||
## points rather than the soft edge, because what this answers is "why is THAT
|
||||
## dark", and the answer is always which ray hit what.
|
||||
static func draw_sight(c: CanvasItem, sight: Visibility) -> void:
|
||||
var n := sight.angles.size()
|
||||
if n < 2:
|
||||
return
|
||||
var outline := PackedVector2Array()
|
||||
outline.resize(n + 1)
|
||||
for i in n:
|
||||
outline[i] = sight.eye + sight.dirs[i] * sight.hits[i]
|
||||
outline[n] = outline[0]
|
||||
c.draw_polyline(outline, COL_SIGHT, 1.0)
|
||||
@@ -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
|
||||
## F1 overlay: draws what the simulation collides against, over what is drawn.
|
||||
var debug_hitboxes: bool = false
|
||||
## What the local player can see this frame. See [Visibility].
|
||||
var _sight := Visibility.new()
|
||||
## The darkness is the window's clear colour, exactly, so where it is complete it
|
||||
## is indistinguishable from the undrawn void beyond it and there is no seam.
|
||||
var _dark: Color = ProjectSettings.get_setting(
|
||||
"rendering/environment/defaults/default_clear_color", Color.BLACK)
|
||||
## How far past the edge of sight the darkness is drawn. It has to cover every
|
||||
## tile and sprite that is drawn at all -- a tile touching the circle of sight
|
||||
## reaches 45 units past it, a boss sprite further.
|
||||
const DARK_REACH := 128.0
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
@@ -50,28 +60,56 @@ func _process(delta: float) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Draw order is the fog's whole mechanism, so it is worth spelling out.
|
||||
##
|
||||
## Everything that fog may hide is drawn first, unconditionally within sight
|
||||
## range; then the darkness goes over it. That is what lets a tile -- or an enemy
|
||||
## stepping round a corner -- be half lit: nothing is decided per object, the
|
||||
## shadow simply covers whatever is behind it. Telegraphs go on top of the
|
||||
## darkness because a warning you cannot see is no warning (see
|
||||
## _draw_telegraphs). The local player goes on top because it is the eye. And
|
||||
## bullets are a child node, so they are drawn after all of this, over the dark
|
||||
## -- as they always were: a shot coming out of the black is a shot you can
|
||||
## still dodge.
|
||||
func _draw() -> void:
|
||||
if client == null:
|
||||
return
|
||||
var map: MapGrid = client.world.map
|
||||
if map != null:
|
||||
_sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
|
||||
_draw_terrain()
|
||||
_draw_portals()
|
||||
_draw_upgrade_npc()
|
||||
for l in client.ground_loot():
|
||||
if _visible(l["pos"]):
|
||||
if _in_view(l["pos"]):
|
||||
_draw_loot(l)
|
||||
for e in client.enemies():
|
||||
if _visible(e["pos"]):
|
||||
if _in_view(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
_draw_telegraphs()
|
||||
for p in client.remote_players():
|
||||
if _visible(p["pos"]):
|
||||
if _in_view(p["pos"]):
|
||||
_draw_remote_player(p)
|
||||
if map != null:
|
||||
_draw_darkness()
|
||||
_draw_telegraphs()
|
||||
_draw_local_player()
|
||||
if debug_hitboxes:
|
||||
_draw_debug()
|
||||
|
||||
|
||||
## Everything the player cannot see, as one batch of triangles.
|
||||
##
|
||||
## Straight to the RenderingServer rather than through draw_polygon: a CanvasItem
|
||||
## draw call is a thin wrapper around exactly this, but it takes one polygon,
|
||||
## and the shadow is several hundred. Issued from inside _draw on this item's own
|
||||
## canvas, so it lands in order between what was drawn before and after it.
|
||||
func _draw_darkness() -> void:
|
||||
var mesh := _sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
|
||||
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
|
||||
mesh["indices"], mesh["points"], mesh["colors"])
|
||||
|
||||
|
||||
## Overlay showing what actually collides, on top of what is drawn. Deliberately
|
||||
## ignores fog: the point is to check that art and simulation line up, and
|
||||
## hiding half the evidence would defeat that.
|
||||
@@ -91,6 +129,7 @@ func _draw_debug() -> void:
|
||||
DebugDraw.draw_player(self, p["pos"], p["aim"])
|
||||
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
|
||||
DebugDraw.draw_ranges(self, client.predicted_pos)
|
||||
DebugDraw.draw_sight(self, _sight)
|
||||
|
||||
|
||||
## Aggro radius by visual index. Read from the content definitions rather than
|
||||
@@ -103,15 +142,16 @@ func _aggro_for(visual: int) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
## Terrain, drawn only where the player can currently see it.
|
||||
## Terrain within sight range, all of it. Whether it can actually be SEEN is
|
||||
## decided afterwards, by the darkness drawn over it -- see _draw.
|
||||
##
|
||||
## Hard fog: nothing outside line of sight is drawn, including ground already
|
||||
## walked over. Note what this is and is not -- it is a rendering rule, not a
|
||||
## secrecy mechanism. The client holds real tile data because it simulates
|
||||
## bullets, so a modified client could draw what is hidden here. What it cannot
|
||||
## do is see terrain the server never streamed (only a radius around the
|
||||
## player) or actors the server filtered out, which is where the line actually
|
||||
## sits.
|
||||
## Still hard fog: nothing is remembered, and ground you walked over is dark
|
||||
## again once it is behind you. Note what this is and is not -- a rendering
|
||||
## rule, not a secrecy mechanism. The client holds real tile data because it
|
||||
## simulates bullets, so a modified client could draw what is hidden here. What
|
||||
## it cannot do is see terrain the server never streamed (only a radius around
|
||||
## the player) or actors the server filtered out, which is where the line
|
||||
## actually sits.
|
||||
func _draw_terrain() -> void:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
@@ -121,15 +161,18 @@ func _draw_terrain() -> void:
|
||||
var lo := map.to_tile(eye - Vector2(view, view))
|
||||
var hi := map.to_tile(eye + Vector2(view, view))
|
||||
var view_sq := view * view
|
||||
var half := Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5
|
||||
for ty in range(lo.y, hi.y + 1):
|
||||
for tx in range(lo.x, hi.x + 1):
|
||||
var kind := map.at(tx, ty)
|
||||
if kind == MapGrid.Kind.UNKNOWN:
|
||||
continue
|
||||
var centre := map.tile_centre(tx, ty)
|
||||
if eye.distance_squared_to(centre) > view_sq:
|
||||
continue
|
||||
if not map.has_line_of_sight(eye, centre):
|
||||
# The tile's nearest point, not its centre: a tile the circle of
|
||||
# sight cuts through is partly inside it, and skipping it would put
|
||||
# a notch in the edge.
|
||||
var near := eye.clamp(centre - half, centre + half)
|
||||
if eye.distance_squared_to(near) > view_sq:
|
||||
continue
|
||||
_draw_tile(kind, centre, tx, ty)
|
||||
|
||||
@@ -158,16 +201,14 @@ func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void:
|
||||
draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
|
||||
|
||||
|
||||
## Actors are only drawn where the player can actually see them. The server
|
||||
## already withholds distant ones; this hides the ones behind walls.
|
||||
func _visible(at: Vector2) -> bool:
|
||||
var map: MapGrid = client.world.map
|
||||
if map == null:
|
||||
return true
|
||||
var eye := client.predicted_pos
|
||||
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
|
||||
return false
|
||||
return map.has_line_of_sight(eye, at)
|
||||
## Whether an actor is near enough to be worth drawing. Distance only: whether
|
||||
## it is behind a wall is the darkness's job, not this one's, and that is what
|
||||
## lets something stepping round a corner be half visible instead of popping in
|
||||
## whole the moment its centre clears the edge. The margin lets a large sprite
|
||||
## standing just past the circle of sight show the part of it that is inside.
|
||||
func _in_view(at: Vector2) -> bool:
|
||||
var reach := SimConfig.FOG_VIEW_RADIUS + 48.0
|
||||
return client.predicted_pos.distance_squared_to(at) <= reach * reach
|
||||
|
||||
|
||||
## One ring per entrance, coloured and labelled by the dungeon it opens. The
|
||||
@@ -192,7 +233,7 @@ func _draw_upgrade_npc() -> void:
|
||||
if client.instance_kind != Protocol.InstanceKind.LOBBY:
|
||||
return
|
||||
var at := client.upgrade_npc
|
||||
if not _visible(at):
|
||||
if not _in_view(at):
|
||||
return
|
||||
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
|
||||
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
|
||||
@@ -256,8 +297,8 @@ func _draw_enemy(e: Dictionary) -> void:
|
||||
|
||||
## Attacks that have been announced but not yet landed.
|
||||
##
|
||||
## Drawn through fog and through walls, deliberately. Everything else in this
|
||||
## view respects line of sight, but a warning you cannot see is not a warning --
|
||||
## Drawn over the darkness and through walls, deliberately. Everything else in
|
||||
## this view is covered by it, but a warning you cannot see is not a warning --
|
||||
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
|
||||
## exist is to make the strike a decision. The marker is inside the boss arena
|
||||
## you are already standing in, so this hides nothing a player could not walk
|
||||
@@ -278,7 +319,7 @@ func _draw_telegraphs() -> void:
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
if b.is_empty() or not _in_view(b["pos"]):
|
||||
return
|
||||
var pos: Vector2 = b["pos"]
|
||||
var visual := client.boss_def.visual if client.boss_def != null else 0
|
||||
|
||||
Reference in new issue
Block a user