Merge branch 'fix/issue-44'
# Conflicts: # src/scene/act-scene.ts
This commit is contained in:
commit
c83499c591
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ import type { AtlasFrame, SpriteAtlas } from '../render/atlas.ts'
|
||||||
import { SpriteRenderer } from '../render/renderer.ts'
|
import { SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasHandle } from '../render/renderer.ts'
|
import type { AtlasHandle } from '../render/renderer.ts'
|
||||||
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
||||||
|
import { RoofFadeController } from '../render/roofs.ts'
|
||||||
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
|
import { loadCharacterSheet, facingToDirection } from '../game/character.ts'
|
||||||
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
|
import { loadMonsterAtlas, type LoadedMonsterArt } from '../game/monster-art.ts'
|
||||||
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
||||||
|
|
@ -161,6 +162,8 @@ export interface ActSceneState {
|
||||||
objects: number
|
objects: number
|
||||||
/** Roof draws, painted in their own pass after the character. */
|
/** Roof draws, painted in their own pass after the character. */
|
||||||
roofs: number
|
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). */
|
/** Whether the composited Sorceress sprite is in use (else the marker). */
|
||||||
character: boolean
|
character: boolean
|
||||||
/** DCC members that went into the character sprite. */
|
/** DCC members that went into the character sprite. */
|
||||||
|
|
@ -212,7 +215,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: ActSceneState = {
|
const state: ActSceneState = {
|
||||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0,
|
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
||||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||||
|
|
@ -1360,6 +1363,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
let wallBounds = buildBounds(runtime.walls)
|
let wallBounds = buildBounds(runtime.walls)
|
||||||
let roofBounds = buildBounds(runtime.roofs)
|
let roofBounds = buildBounds(runtime.roofs)
|
||||||
let objectBounds = buildBounds(runtime.objectDrawables)
|
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
|
// `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
|
// re-read every frame. A loop is used rather than `filter().length` because
|
||||||
|
|
@ -1522,6 +1526,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
wallBounds = buildBounds(next.walls)
|
wallBounds = buildBounds(next.walls)
|
||||||
roofBounds = buildBounds(next.roofs)
|
roofBounds = buildBounds(next.roofs)
|
||||||
objectBounds = buildBounds(next.objectDrawables)
|
objectBounds = buildBounds(next.objectDrawables)
|
||||||
|
roofController = new RoofFadeController(next.roofs)
|
||||||
|
|
||||||
camera.mapWidthPx = next.widthPx
|
camera.mapWidthPx = next.widthPx
|
||||||
camera.mapHeightPx = next.heightPx
|
camera.mapHeightPx = next.heightPx
|
||||||
|
|
@ -1819,7 +1824,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
dialogPanel.textContent = state.dialog.join('\n')
|
dialogPanel.textContent = state.dialog.join('\n')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRender: () => {
|
onRender: (_alpha: number, frameMs: number) => {
|
||||||
const renderStarted = performance.now()
|
const renderStarted = performance.now()
|
||||||
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
|
if (runtime.animatedDrawables && runtime.animatedDrawables.length > 0) {
|
||||||
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
|
updateAnimatableTiles(runtime.animatedDrawables, renderStarted, 'time')
|
||||||
|
|
@ -1846,8 +1851,25 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
renderer.draw(draw.frame, draw.x, draw.y, { atlas: page })
|
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)
|
drawTiles(runtime.floors, floorBounds)
|
||||||
const cell = cellOf(runtime.grid, engine.world.player.x, engine.world.player.y)
|
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
|
const playerDepth = cell.x + cell.y
|
||||||
|
|
||||||
let wallIdx = 0
|
let wallIdx = 0
|
||||||
|
|
@ -1986,7 +2008,7 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
drawUpToDepth(Infinity)
|
drawUpToDepth(Infinity)
|
||||||
// Roofs last, in their own pass: the engine paints them after every other
|
// 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.
|
// 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.
|
// Last in the batch, so the panel sits over every world layer.
|
||||||
minimap.draw(
|
minimap.draw(
|
||||||
renderer,
|
renderer,
|
||||||
|
|
@ -2195,6 +2217,7 @@ function publish(runtime: MapRuntime, base: string): void {
|
||||||
state.objects = runtime.objects
|
state.objects = runtime.objects
|
||||||
state.roofs = runtime.roofs.length
|
state.roofs = runtime.roofs.length
|
||||||
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
||||||
|
state.roofAlpha = 1
|
||||||
state.missing = 0
|
state.missing = 0
|
||||||
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue