feat(renderer/roofs): implement dynamic roof fade when player enters shelter (fixes #44)

This commit is contained in:
troytt 2026-09-17 06:19:01 +00:00
parent 1fa3f8b7a2
commit 57c13bdc26
3 changed files with 969 additions and 3 deletions

575
src/render/roofs.ts Normal file
View File

@ -0,0 +1,575 @@
/**
* Dynamic roof occlusion detection and opacity fade (Roof Fade).
*
* In Diablo II, roof tiles (`TileRoof = 15`) are placed over shelters like
* Akara's tent, Charsi's forge, and town houses. They are rendered in Pass 4
* (topmost layer) after floors, walls, and characters.
*
* When the player steps inside a shelter or under an overhanging roof, the roof
* tile(s) occlude the character. This module detects player occlusion
* (via bounding box overlap or cell/sub-tile coverage) and smoothly transitions
* the roof opacity between 1.0 (opaque) and a semi-transparent alpha (e.g. 0.25),
* restoring full opacity upon exit.
*/
/** Minimal roof tile drawable interface. */
export interface RoofDrawable {
readonly frame: { readonly width: number; readonly height: number }
readonly x: number
readonly y: number
readonly cellX?: number
readonly cellY?: number
}
/** 2D axis-aligned bounding box in world/scene coordinates. */
export interface RoofBounds {
readonly minX: number
readonly minY: number
readonly maxX: number
readonly maxY: number
}
/** Point position in world/scene pixels. */
export interface Point2D {
readonly x: number
readonly y: number
}
/** Axis-aligned bounding box with left/top/right/bottom. */
export interface Box2D {
readonly left: number
readonly top: number
readonly right: number
readonly bottom: number
}
/** Player target representation: a point { x, y }, a box { left, top, right, bottom }, or RoofBounds. */
export type PlayerTarget = Point2D | Box2D | RoofBounds
/** Configuration options for roof fade and occlusion detection. */
export interface RoofFadeConfig {
/**
* Minimum opacity when player is under roof (0.0 .. 1.0).
* Default: 0.25 (within 0.0 ~ 0.3 as specified in Issue #44).
*/
readonly minAlpha?: number
/**
* Maximum opacity when player is outside (0.0 .. 1.0).
* Default: 1.0.
*/
readonly maxAlpha?: number
/**
* Fade transition duration in milliseconds.
* Default: 200 ms.
*/
readonly fadeDurationMs?: number
/**
* Player bounding box half-width (padding) for point-based player positions.
* Default: 16 pixels.
*/
readonly playerPaddingX?: number
/**
* Player bounding box height for point-based player positions (feet to head).
* Default: 48 pixels.
*/
readonly playerHeight?: number
/**
* Maximum cell distance to cluster adjacent roof tiles into a single shelter.
* Default: 2 cells.
*/
readonly clusterCellDistance?: number
/**
* Pixel proximity margin to connect roof bounding boxes into a cluster.
* Default: 40 pixels.
*/
readonly clusterPixelMargin?: number
}
export const DEFAULT_MIN_ROOF_ALPHA = 0.25
export const DEFAULT_MAX_ROOF_ALPHA = 1.0
export const DEFAULT_ROOF_FADE_DURATION_MS = 200
export const DEFAULT_PLAYER_PADDING_X = 16
export const DEFAULT_PLAYER_HEIGHT = 48
export const DEFAULT_CLUSTER_CELL_DISTANCE = 2
export const DEFAULT_CLUSTER_PIXEL_MARGIN = 40
/**
* Compute the world-space bounding box of a single roof tile.
*
* @param roof - Roof drawable with position and dimensions.
* @returns 2D axis-aligned bounding box.
*/
export function getRoofBounds(roof: RoofDrawable): RoofBounds {
return {
minX: roof.x,
minY: roof.y,
maxX: roof.x + roof.frame.width,
maxY: roof.y + roof.frame.height,
}
}
/**
* Compute the combined bounding box enclosing an array of roof tiles.
*
* @param roofs - Array of roof drawables.
* @returns Combined bounding box, or null if the array is empty.
*/
export function getCombinedRoofBounds(roofs: readonly RoofDrawable[]): RoofBounds | null {
if (roofs.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (let i = 0; i < roofs.length; i += 1) {
const r = roofs[i]!
if (r.x < minX) minX = r.x
if (r.y < minY) minY = r.y
const rMaxX = r.x + r.frame.width
const rMaxY = r.y + r.frame.height
if (rMaxX > maxX) maxX = rMaxX
if (rMaxY > maxY) maxY = rMaxY
}
return { minX, minY, maxX, maxY }
}
/**
* Check if a 2D point lies within a bounding box (inclusive).
*
* @param px - Point x coordinate.
* @param py - Point y coordinate.
* @param bounds - Bounding box.
*/
export function isPointInRoofBounds(px: number, py: number, bounds: RoofBounds): boolean {
return px >= bounds.minX && px <= bounds.maxX && py >= bounds.minY && py <= bounds.maxY
}
/**
* Check if two axis-aligned bounding boxes overlap.
*
* @param a - First bounding box.
* @param b - Second bounding box.
*/
export function boundsOverlap(a: RoofBounds, b: RoofBounds): boolean {
return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY
}
/**
* Normalise a player target (point or box) into a world-space bounding box.
*
* @param player - Point { x, y }, Box { left, top, right, bottom }, or RoofBounds.
* @param paddingX - Half-width when expanding a point (default: 16).
* @param height - Height when expanding a point upward from feet (default: 48).
*/
export function getPlayerBounds(
player: PlayerTarget,
paddingX = DEFAULT_PLAYER_PADDING_X,
height = DEFAULT_PLAYER_HEIGHT,
): RoofBounds {
if ('minX' in player && 'minY' in player && 'maxX' in player && 'maxY' in player) {
return player
}
if ('left' in player && 'right' in player && 'top' in player && 'bottom' in player) {
return {
minX: Math.min(player.left, player.right),
minY: Math.min(player.top, player.bottom),
maxX: Math.max(player.left, player.right),
maxY: Math.max(player.top, player.bottom),
}
}
return {
minX: player.x - paddingX,
minY: player.y - height,
maxX: player.x + paddingX,
maxY: player.y,
}
}
/**
* Check if a player's position or bounding box overlaps a roof's bounding box.
*
* @param player - Player position or box.
* @param roof - Roof drawable or precomputed bounds.
* @param paddingX - Optional player padding in x.
* @param playerHeight - Optional player height.
*/
export function isPlayerOverlappingRoofBounds(
player: PlayerTarget,
roof: RoofDrawable | RoofBounds,
paddingX = DEFAULT_PLAYER_PADDING_X,
playerHeight = DEFAULT_PLAYER_HEIGHT,
): boolean {
const pBounds = getPlayerBounds(player, paddingX, playerHeight)
const rBounds = 'frame' in roof ? getRoofBounds(roof) : roof
return boundsOverlap(pBounds, rBounds)
}
/**
* Check if player's cell matches any roof tile's cell.
*
* @param playerCell - Player cell coordinates { x, y }.
* @param roofs - Roof drawable(s) or array with cellX/cellY.
*/
export function isPlayerInRoofCell(
playerCell: Point2D,
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
): boolean {
if (Array.isArray(roofs)) {
for (let i = 0; i < roofs.length; i += 1) {
const r = roofs[i]!
if (r.cellX !== undefined && r.cellY !== undefined && r.cellX === playerCell.x && r.cellY === playerCell.y) {
return true
}
}
return false
}
const single = roofs as { readonly cellX?: number; readonly cellY?: number }
return single.cellX !== undefined && single.cellY !== undefined && single.cellX === playerCell.x && single.cellY === playerCell.y
}
/**
* Check if player's sub-tile coordinates fall inside any cell covered by a roof.
* In Diablo II, 1 cell = 5x5 sub-tiles (subTilesPerTile = 5).
*
* @param playerSubTile - Player sub-tile coordinates { x, y }.
* @param roofs - Roof drawable(s) with cellX/cellY.
* @param subTilesPerTile - Sub-tiles per cell edge (default: 5).
*/
export function isPlayerInRoofSubTile(
playerSubTile: Point2D,
roofs: { readonly cellX?: number; readonly cellY?: number } | readonly { readonly cellX?: number; readonly cellY?: number }[],
subTilesPerTile = 5,
): boolean {
const cellX = Math.floor(playerSubTile.x / subTilesPerTile)
const cellY = Math.floor(playerSubTile.y / subTilesPerTile)
return isPlayerInRoofCell({ x: cellX, y: cellY }, roofs)
}
/**
* Determine whether player is under a specific roof tile, checking bounding box
* overlap OR cell/sub-tile match.
*
* @param player - Player coordinates or bounding box.
* @param roof - Roof drawable.
* @param playerCell - Optional player cell coordinates.
* @param config - Optional configuration.
*/
export function isPlayerUnderRoof(
player: PlayerTarget,
roof: RoofDrawable,
playerCell?: Point2D,
config?: RoofFadeConfig,
): boolean {
if (playerCell && roof.cellX !== undefined && roof.cellY !== undefined) {
if (roof.cellX === playerCell.x && roof.cellY === playerCell.y) {
return true
}
}
return isPlayerOverlappingRoofBounds(
player,
roof,
config?.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
config?.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
)
}
/**
* Step an opacity value towards targetAlpha given elapsed time deltaMs and duration.
*
* @param currentAlpha - Current alpha (0.0 .. 1.0).
* @param targetAlpha - Target alpha (0.0 .. 1.0).
* @param deltaMs - Elapsed wall-clock milliseconds.
* @param durationMs - Transition duration in ms (default: 200).
* @param minAlpha - Minimum allowed alpha (default: 0.25).
* @param maxAlpha - Maximum allowed alpha (default: 1.0).
*/
export function stepRoofAlpha(
currentAlpha: number,
targetAlpha: number,
deltaMs: number,
durationMs = DEFAULT_ROOF_FADE_DURATION_MS,
minAlpha = DEFAULT_MIN_ROOF_ALPHA,
maxAlpha = DEFAULT_MAX_ROOF_ALPHA,
): number {
if (durationMs <= 0) return targetAlpha
if (deltaMs <= 0) return currentAlpha
const speed = (maxAlpha - minAlpha) / durationMs
const change = speed * deltaMs
if (currentAlpha < targetAlpha) {
return Math.min(targetAlpha, currentAlpha + change)
}
if (currentAlpha > targetAlpha) {
return Math.max(targetAlpha, currentAlpha - change)
}
return currentAlpha
}
/** Represents a clustered shelter composed of one or more adjacent roof tiles. */
export interface RoofCluster {
readonly id: number
readonly roofs: readonly RoofDrawable[]
readonly roofIndices: readonly number[]
readonly bounds: RoofBounds
readonly cells: ReadonlySet<string>
/** Current opacity alpha (0.0 .. 1.0). */
alpha: number
/** Target opacity alpha (0.0 .. 1.0). */
targetAlpha: number
/** Whether the player was detected under this cluster on the latest update. */
isPlayerUnder: boolean
}
/**
* Group roof drawables into connected shelters (clusters).
*
* Roof tiles that share adjacent cells or have overlapping/nearby bounding boxes
* are grouped together so an entire building/tent roof fades in unison.
*
* @param roofs - Array of roof drawables.
* @param config - Clustering parameters.
*/
export function clusterRoofs(
roofs: readonly RoofDrawable[],
config: RoofFadeConfig = {},
): RoofCluster[] {
if (roofs.length === 0) return []
const cellDist = config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE
const pixelMargin = config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN
const n = roofs.length
const parent = new Int32Array(n)
for (let i = 0; i < n; i += 1) parent[i] = i
const find = (i: number): number => {
let root = i
while (parent[root] !== root) root = parent[root]!
let curr = i
while (curr !== root) {
const nxt = parent[curr]!
parent[curr] = root
curr = nxt
}
return root
}
const union = (i: number, j: number): void => {
const rootI = find(i)
const rootJ = find(j)
if (rootI !== rootJ) parent[rootI] = rootJ
}
const boundsList = roofs.map(getRoofBounds)
for (let i = 0; i < n; i += 1) {
const a = roofs[i]!
const bA = boundsList[i]!
const expandedA: RoofBounds = {
minX: bA.minX - pixelMargin,
minY: bA.minY - pixelMargin,
maxX: bA.maxX + pixelMargin,
maxY: bA.maxY + pixelMargin,
}
for (let j = i + 1; j < n; j += 1) {
const b = roofs[j]!
let connected = false
// 1. Cell adjacency
if (
a.cellX !== undefined && a.cellY !== undefined &&
b.cellX !== undefined && b.cellY !== undefined
) {
if (
Math.abs(a.cellX - b.cellX) <= cellDist &&
Math.abs(a.cellY - b.cellY) <= cellDist
) {
connected = true
}
}
// 2. Pixel bounding box proximity
if (!connected && boundsOverlap(expandedA, boundsList[j]!)) {
connected = true
}
if (connected) union(i, j)
}
}
const groups = new Map<number, number[]>()
for (let i = 0; i < n; i += 1) {
const root = find(i)
const existing = groups.get(root)
if (existing !== undefined) {
existing.push(i)
} else {
groups.set(root, [i])
}
}
const maxAlpha = config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA
const clusters: RoofCluster[] = []
let clusterId = 0
for (const indices of groups.values()) {
const clusterRoofsList: RoofDrawable[] = []
const cells = new Set<string>()
for (const idx of indices) {
const r = roofs[idx]!
clusterRoofsList.push(r)
if (r.cellX !== undefined && r.cellY !== undefined) {
cells.add(`${String(r.cellX)},${String(r.cellY)}`)
}
}
const bounds = getCombinedRoofBounds(clusterRoofsList)!
clusters.push({
id: clusterId,
roofs: clusterRoofsList,
roofIndices: indices,
bounds,
cells,
alpha: maxAlpha,
targetAlpha: maxAlpha,
isPlayerUnder: false,
})
clusterId += 1
}
return clusters
}
/**
* Controller managing roof opacity and dynamic roof fading for a scene.
*/
export class RoofFadeController {
readonly clusters: RoofCluster[]
readonly roofClusterMap: Int32Array
readonly config: Required<RoofFadeConfig>
constructor(
roofs: readonly RoofDrawable[],
config: RoofFadeConfig = {},
) {
this.config = {
minAlpha: config.minAlpha ?? DEFAULT_MIN_ROOF_ALPHA,
maxAlpha: config.maxAlpha ?? DEFAULT_MAX_ROOF_ALPHA,
fadeDurationMs: config.fadeDurationMs ?? DEFAULT_ROOF_FADE_DURATION_MS,
playerPaddingX: config.playerPaddingX ?? DEFAULT_PLAYER_PADDING_X,
playerHeight: config.playerHeight ?? DEFAULT_PLAYER_HEIGHT,
clusterCellDistance: config.clusterCellDistance ?? DEFAULT_CLUSTER_CELL_DISTANCE,
clusterPixelMargin: config.clusterPixelMargin ?? DEFAULT_CLUSTER_PIXEL_MARGIN,
}
this.clusters = clusterRoofs(roofs, this.config)
this.roofClusterMap = new Int32Array(roofs.length)
for (let cIdx = 0; cIdx < this.clusters.length; cIdx += 1) {
const cluster = this.clusters[cIdx]!
for (const rIdx of cluster.roofIndices) {
this.roofClusterMap[rIdx] = cIdx
}
}
}
/**
* Update opacity for all roof clusters based on player position and elapsed time.
*
* @param player - Current player world position or bounding box.
* @param deltaMs - Elapsed wall-clock time in milliseconds.
* @param playerCell - Optional player cell { x, y } in tile grid.
*/
update(
player: PlayerTarget,
deltaMs: number,
playerCell?: Point2D,
): void {
const pBounds = getPlayerBounds(player, this.config.playerPaddingX, this.config.playerHeight)
for (let i = 0; i < this.clusters.length; i += 1) {
const cluster = this.clusters[i]!
let under = false
// 1. Check cell coverage
if (playerCell && cluster.cells.has(`${String(playerCell.x)},${String(playerCell.y)}`)) {
under = true
}
// 2. Check bounding box overlap if not already triggered by cell
if (!under && boundsOverlap(pBounds, cluster.bounds)) {
for (let r = 0; r < cluster.roofs.length; r += 1) {
if (isPlayerOverlappingRoofBounds(pBounds, cluster.roofs[r]!)) {
under = true
break
}
}
}
cluster.isPlayerUnder = under
cluster.targetAlpha = under ? this.config.minAlpha : this.config.maxAlpha
cluster.alpha = stepRoofAlpha(
cluster.alpha,
cluster.targetAlpha,
deltaMs,
this.config.fadeDurationMs,
this.config.minAlpha,
this.config.maxAlpha,
)
}
}
/**
* Get the current opacity alpha for a roof drawable by its index.
*
* @param roofIndex - Index of the roof in the original list passed to the constructor.
* @returns Opacity alpha in range [minAlpha, maxAlpha].
*/
getAlpha(roofIndex: number): number {
const clusterIdx = this.roofClusterMap[roofIndex]
if (clusterIdx === undefined || clusterIdx < 0) return this.config.maxAlpha
return this.clusters[clusterIdx]?.alpha ?? this.config.maxAlpha
}
/**
* Set alpha directly for all clusters (useful for testing or scene reset).
*/
setAlpha(alpha: number): void {
for (let i = 0; i < this.clusters.length; i += 1) {
this.clusters[i]!.alpha = alpha
this.clusters[i]!.targetAlpha = alpha
}
}
/**
* Whether the player is currently detected under any roof cluster.
*/
get isPlayerUnderAnyRoof(): boolean {
for (let i = 0; i < this.clusters.length; i += 1) {
if (this.clusters[i]!.isPlayerUnder) return true
}
return false
}
/**
* Lowest opacity alpha among all roof clusters (for global state/HUD reporting).
*/
get overallAlpha(): number {
if (this.clusters.length === 0) return this.config.maxAlpha
let min = 1.0
for (let i = 0; i < this.clusters.length; i += 1) {
if (this.clusters[i]!.alpha < min) min = this.clusters[i]!.alpha
}
return min
}
}

View File

@ -46,6 +46,7 @@ import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { RoofFadeController } from '../render/roofs.ts'
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
@ -154,6 +155,8 @@ export interface ActSceneState {
objects: number
/** Roof draws, painted in their own pass after the character. */
roofs: number
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
roofAlpha: number
/** Whether the composited Sorceress sprite is in use (else the marker). */
character: boolean
/** DCC members that went into the character sprite. */
@ -203,7 +206,7 @@ declare global {
}
const state: ActSceneState = {
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0,
ready: false, source: '', act: 1, level: '', quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
@ -1300,6 +1303,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
let wallBounds = buildBounds(runtime.walls)
let roofBounds = buildBounds(runtime.roofs)
let objectBounds = buildBounds(runtime.objectDrawables)
let roofController = new RoofFadeController(runtime.roofs)
// `runtime.pages` is filled in by background loaders, so the count has to be
// re-read every frame. A loop is used rather than `filter().length` because
@ -1462,6 +1466,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
wallBounds = buildBounds(next.walls)
roofBounds = buildBounds(next.roofs)
objectBounds = buildBounds(next.objectDrawables)
roofController = new RoofFadeController(next.roofs)
camera.mapWidthPx = next.widthPx
camera.mapHeightPx = next.heightPx
@ -1751,7 +1756,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
dialogPanel.textContent = state.dialog.join('\n')
}
},
onRender: () => {
onRender: (_alpha: number, frameMs: number) => {
const renderStarted = performance.now()
if (state.pagesAtFirstFrame < 0) {
state.pagesAtFirstFrame = countPages()
@ -1775,8 +1780,25 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
const drawRoofs = (list: readonly Drawable[], bounds: Float32Array): void => {
for (let index = 0; index < list.length; index += 1) {
if (!visible(bounds, index)) { culledDraws += 1; continue }
const draw = list[index]!
const page = runtime.pages[draw.page]
if (page === null || page === undefined) { skippedDraws += 1; continue }
const alpha = roofController.getAlpha(index)
if (alpha < 0.999) {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page, tint: [1, 1, 1, alpha] })
} else {
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
}
}
}
drawTiles(runtime.floors, floorBounds)
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
const deltaMs = Number.isFinite(frameMs) && frameMs > 0 ? Math.min(frameMs, 100) : 16
roofController.update(engine.world.player, deltaMs, cell)
state.roofAlpha = roofController.overallAlpha
const playerDepth = cell.x + cell.y
let wallIdx = 0
@ -1915,7 +1937,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
drawUpToDepth(Infinity)
// Roofs last, in their own pass: the engine paints them after every other
// layer so they cover the floor, the walls and anything walking under them.
drawTiles(runtime.roofs, roofBounds)
drawRoofs(runtime.roofs, roofBounds)
// Last in the batch, so the panel sits over every world layer.
minimap.draw(
renderer,
@ -2121,6 +2143,7 @@ function publish(runtime: MapRuntime, base: string): void {
state.priorityPages = [...runtime.priorityPages]
state.objects = runtime.objects
state.roofs = runtime.roofs.length
state.roofAlpha = 1
state.missing = 0
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
}

368
tests/roofs.test.ts Normal file
View File

@ -0,0 +1,368 @@
import { describe, expect, it } from 'vitest'
import {
getRoofBounds,
getCombinedRoofBounds,
isPointInRoofBounds,
boundsOverlap,
getPlayerBounds,
isPlayerOverlappingRoofBounds,
isPlayerInRoofCell,
isPlayerInRoofSubTile,
isPlayerUnderRoof,
stepRoofAlpha,
clusterRoofs,
RoofFadeController,
DEFAULT_MIN_ROOF_ALPHA,
DEFAULT_MAX_ROOF_ALPHA,
DEFAULT_ROOF_FADE_DURATION_MS,
type RoofDrawable,
} from '../src/render/roofs.ts'
/** Helper to create a minimal roof tile. */
function makeRoof(
x: number,
y: number,
width: number,
height: number,
cellX?: number,
cellY?: number,
): RoofDrawable {
return {
x,
y,
frame: { width, height },
...(cellX !== undefined ? { cellX } : {}),
...(cellY !== undefined ? { cellY } : {}),
}
}
describe('Roof Bounding Detection', () => {
it('computes correct bounds for a single roof tile', () => {
const roof = makeRoof(100, 200, 80, 60)
const bounds = getRoofBounds(roof)
expect(bounds).toEqual({
minX: 100,
minY: 200,
maxX: 180,
maxY: 260,
})
})
it('computes correct bounds with negative coordinates and offsets', () => {
const roof = makeRoof(-50, -30, 100, 70)
const bounds = getRoofBounds(roof)
expect(bounds).toEqual({
minX: -50,
minY: -30,
maxX: 50,
maxY: 40,
})
})
it('returns null for combined bounds of empty roof list', () => {
expect(getCombinedRoofBounds([])).toBeNull()
})
it('computes combined bounding box enclosing multiple roof tiles', () => {
const roofs = [
makeRoof(100, 150, 40, 40),
makeRoof(130, 180, 50, 30),
makeRoof(80, 200, 30, 60),
]
const combined = getCombinedRoofBounds(roofs)
expect(combined).toEqual({
minX: 80,
minY: 150,
maxX: 180, // 130 + 50
maxY: 260, // 80..110, 200 + 60
})
})
it('detects whether a 2D point is inside roof bounds', () => {
const bounds = { minX: 50, minY: 100, maxX: 150, maxY: 200 }
// Inside
expect(isPointInRoofBounds(100, 150, bounds)).toBe(true)
// Boundaries (inclusive)
expect(isPointInRoofBounds(50, 100, bounds)).toBe(true)
expect(isPointInRoofBounds(150, 200, bounds)).toBe(true)
// Outside
expect(isPointInRoofBounds(49, 150, bounds)).toBe(false)
expect(isPointInRoofBounds(151, 150, bounds)).toBe(false)
expect(isPointInRoofBounds(100, 99, bounds)).toBe(false)
expect(isPointInRoofBounds(100, 201, bounds)).toBe(false)
})
it('detects bounding box overlap and non-overlap', () => {
const boxA = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
// Fully overlapping
expect(boundsOverlap(boxA, { minX: 20, minY: 20, maxX: 80, maxY: 80 })).toBe(true)
// Partially overlapping
expect(boundsOverlap(boxA, { minX: 50, minY: 50, maxX: 150, maxY: 150 })).toBe(true)
// Touching edge
expect(boundsOverlap(boxA, { minX: 100, minY: 0, maxX: 200, maxY: 100 })).toBe(true)
// Separated in X
expect(boundsOverlap(boxA, { minX: 101, minY: 0, maxX: 200, maxY: 100 })).toBe(false)
// Separated in Y
expect(boundsOverlap(boxA, { minX: 0, minY: 101, maxX: 100, maxY: 200 })).toBe(false)
})
})
describe('Player-Under-Roof Condition', () => {
const roof = makeRoof(100, 100, 80, 80, 5, 10)
describe('bounding box overlap', () => {
it('detects player point inside roof bounds', () => {
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 140 }, roof)).toBe(true)
})
it('detects player sprite overlapping lower edge of roof', () => {
// Player feet at y=190, sprite extends upward to y=142, overlapping roof [100..180]
expect(isPlayerOverlappingRoofBounds({ x: 140, y: 190 }, roof)).toBe(true)
})
it('returns false when player is far away from roof', () => {
expect(isPlayerOverlappingRoofBounds({ x: 500, y: 500 }, roof)).toBe(false)
expect(isPlayerOverlappingRoofBounds({ x: 20, y: 50 }, roof)).toBe(false)
})
it('supports explicit player bounding box', () => {
expect(isPlayerOverlappingRoofBounds({ left: 110, top: 120, right: 130, bottom: 160 }, roof)).toBe(true)
expect(isPlayerOverlappingRoofBounds({ left: 300, top: 300, right: 320, bottom: 340 }, roof)).toBe(false)
})
})
describe('cell and sub-tile coverage', () => {
it('matches player cell coordinates with roof tile cell', () => {
expect(isPlayerInRoofCell({ x: 5, y: 10 }, roof)).toBe(true)
expect(isPlayerInRoofCell({ x: 4, y: 10 }, roof)).toBe(false)
expect(isPlayerInRoofCell({ x: 5, y: 9 }, roof)).toBe(false)
})
it('matches player cell against an array of roof tiles', () => {
const roofTiles = [
makeRoof(0, 0, 80, 40, 5, 10),
makeRoof(0, 0, 80, 40, 5, 11),
makeRoof(0, 0, 80, 40, 6, 10),
]
expect(isPlayerInRoofCell({ x: 5, y: 11 }, roofTiles)).toBe(true)
expect(isPlayerInRoofCell({ x: 6, y: 10 }, roofTiles)).toBe(true)
expect(isPlayerInRoofCell({ x: 7, y: 12 }, roofTiles)).toBe(false)
})
it('detects player sub-tile coverage (5x5 sub-tiles per cell)', () => {
// Cell (5, 10) corresponds to sub-tiles [25..29, 50..54]
expect(isPlayerInRoofSubTile({ x: 25, y: 50 }, roof)).toBe(true)
expect(isPlayerInRoofSubTile({ x: 29, y: 54 }, roof)).toBe(true)
expect(isPlayerInRoofSubTile({ x: 27, y: 52 }, roof)).toBe(true)
// Adjacent sub-tile in cell (4, 10) or (5, 9)
expect(isPlayerInRoofSubTile({ x: 24, y: 50 }, roof)).toBe(false)
expect(isPlayerInRoofSubTile({ x: 25, y: 49 }, roof)).toBe(false)
})
it('combines cell coverage and bounding box in isPlayerUnderRoof', () => {
// Cell matches even if screen point is outside
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 5, y: 10 })).toBe(true)
// Bounding box matches even if cell is different
expect(isPlayerUnderRoof({ x: 140, y: 140 }, roof, { x: 99, y: 99 })).toBe(true)
// Neither matches
expect(isPlayerUnderRoof({ x: 0, y: 0 }, roof, { x: 99, y: 99 })).toBe(false)
})
})
describe('shelter clustering', () => {
it('groups adjacent roof tiles into a single shelter cluster', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
makeRoof(120, 140, 80, 80, 11, 10),
]
const clusters = clusterRoofs(akaraTent)
expect(clusters).toHaveLength(1)
expect(clusters[0]!.roofs).toHaveLength(3)
expect(clusters[0]!.cells.has('10,10')).toBe(true)
expect(clusters[0]!.cells.has('10,11')).toBe(true)
expect(clusters[0]!.cells.has('11,10')).toBe(true)
})
it('separates distant roof groups into distinct shelters', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
]
const charsiForge = [
makeRoof(800, 800, 80, 80, 50, 50),
makeRoof(840, 820, 80, 80, 50, 51),
]
const clusters = clusterRoofs([...akaraTent, ...charsiForge])
expect(clusters).toHaveLength(2)
const clusterA = clusters.find(c => c.cells.has('10,10'))!
const clusterB = clusters.find(c => c.cells.has('50,50'))!
expect(clusterA.roofs).toHaveLength(2)
expect(clusterB.roofs).toHaveLength(2)
})
})
})
describe('Roof Opacity Fade', () => {
describe('stepRoofAlpha', () => {
it('fades down towards minAlpha over durationMs', () => {
// 200ms total duration, from 1.0 to 0.25
const start = 1.0
const target = 0.25
// After 50ms (25% progress) -> 1.0 - 0.75 * 0.25 = 0.8125
const step1 = stepRoofAlpha(start, target, 50, 200, 0.25, 1.0)
expect(step1).toBeCloseTo(0.8125, 4)
// After 100ms (50% progress) -> 1.0 - 0.75 * 0.5 = 0.625
const step2 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
expect(step2).toBeCloseTo(0.625, 4)
// After 200ms (100% progress) -> exactly 0.25
const step3 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
expect(step3).toBe(0.25)
// Clamps and does not overshoot below minAlpha
const step4 = stepRoofAlpha(0.25, target, 100, 200, 0.25, 1.0)
expect(step4).toBe(0.25)
})
it('fades up towards maxAlpha when exiting shelter', () => {
const start = 0.25
const target = 1.0
// After 100ms -> halfway back to 1.0 (0.625)
const step1 = stepRoofAlpha(start, target, 100, 200, 0.25, 1.0)
expect(step1).toBeCloseTo(0.625, 4)
// After 200ms -> full 1.0
const step2 = stepRoofAlpha(start, target, 200, 200, 0.25, 1.0)
expect(step2).toBe(1.0)
// Clamps at maxAlpha
const step3 = stepRoofAlpha(1.0, target, 50, 200, 0.25, 1.0)
expect(step3).toBe(1.0)
})
it('smoothly reverses direction mid-fade without discontinuities', () => {
// Player enters shelter, fades down partially to 0.70
let alpha = 1.0
alpha = stepRoofAlpha(alpha, 0.25, 80, 200, 0.25, 1.0)
expect(alpha).toBeCloseTo(0.70, 4)
// Player immediately steps back out: smoothly fades back up towards 1.0
alpha = stepRoofAlpha(alpha, 1.0, 40, 200, 0.25, 1.0)
expect(alpha).toBeGreaterThan(0.70)
expect(alpha).toBeCloseTo(0.85, 4)
// Reaches 1.0
alpha = stepRoofAlpha(alpha, 1.0, 100, 200, 0.25, 1.0)
expect(alpha).toBe(1.0)
})
it('returns currentAlpha when deltaMs is zero or negative', () => {
expect(stepRoofAlpha(0.5, 0.25, 0, 200)).toBe(0.5)
expect(stepRoofAlpha(0.5, 0.25, -10, 200)).toBe(0.5)
})
it('snaps immediately to target when durationMs is zero', () => {
expect(stepRoofAlpha(1.0, 0.25, 16, 0)).toBe(0.25)
})
})
describe('RoofFadeController', () => {
const akaraTent = [
makeRoof(100, 100, 80, 80, 10, 10),
makeRoof(140, 120, 80, 80, 10, 11),
]
const charsiForge = [
makeRoof(800, 800, 80, 80, 50, 50),
]
it('initializes all roofs to full opacity (1.0)', () => {
const controller = new RoofFadeController([...akaraTent, ...charsiForge])
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.getAlpha(1)).toBe(1.0)
expect(controller.getAlpha(2)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
})
it('fades only the shelter the player is under', () => {
const controller = new RoofFadeController([...akaraTent, ...charsiForge], {
fadeDurationMs: 200,
minAlpha: 0.25,
})
// Player stands inside Akara's tent at cell (10, 10)
const playerPos = { x: 140, y: 140 }
const playerCell = { x: 10, y: 10 }
// Advance by 100ms (halfway)
controller.update(playerPos, 100, playerCell)
expect(controller.isPlayerUnderAnyRoof).toBe(true)
// Akara's tent tiles (indices 0 and 1) fade in unison
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
expect(controller.getAlpha(1)).toBeCloseTo(0.625, 4)
// Charsi's forge (index 2) remains fully opaque
expect(controller.getAlpha(2)).toBe(1.0)
// Advance by another 100ms (reaches minAlpha 0.25)
controller.update(playerPos, 100, playerCell)
expect(controller.getAlpha(0)).toBe(0.25)
expect(controller.getAlpha(1)).toBe(0.25)
expect(controller.getAlpha(2)).toBe(1.0)
expect(controller.overallAlpha).toBe(0.25)
})
it('smoothly restores full opacity when player exits shelter', () => {
const controller = new RoofFadeController(akaraTent, {
fadeDurationMs: 200,
minAlpha: 0.25,
})
// Player inside -> fully faded
controller.update({ x: 140, y: 140 }, 200, { x: 10, y: 10 })
expect(controller.getAlpha(0)).toBe(0.25)
// Player exits shelter to town square
const outsidePos = { x: 500, y: 500 }
const outsideCell = { x: 25, y: 25 }
// 100ms elapsed
controller.update(outsidePos, 100, outsideCell)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
expect(controller.getAlpha(0)).toBeCloseTo(0.625, 4)
// 200ms elapsed -> fully restored
controller.update(outsidePos, 100, outsideCell)
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
})
it('handles empty roof list safely', () => {
const controller = new RoofFadeController([])
controller.update({ x: 100, y: 100 }, 16)
expect(controller.getAlpha(0)).toBe(1.0)
expect(controller.overallAlpha).toBe(1.0)
expect(controller.isPlayerUnderAnyRoof).toBe(false)
})
it('allows manual alpha override via setAlpha', () => {
const controller = new RoofFadeController(akaraTent)
controller.setAlpha(0.2)
expect(controller.getAlpha(0)).toBe(0.2)
expect(controller.getAlpha(1)).toBe(0.2)
})
})
})