diff --git a/src/render/roofs.ts b/src/render/roofs.ts new file mode 100644 index 0000000..98d0e44 --- /dev/null +++ b/src/render/roofs.ts @@ -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 + /** 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() + 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() + + 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 + + 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 + } +} diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3e71dac..fbe74ca 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 } diff --git a/tests/roofs.test.ts b/tests/roofs.test.ts new file mode 100644 index 0000000..64d5a6f --- /dev/null +++ b/tests/roofs.test.ts @@ -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) + }) + }) +})