From 1568fcbd1086b390933871d27607fc0088e04354 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 09:18:36 +0000 Subject: [PATCH] feat(ground): monster loot and gold ground spawning, isometric scatter, and flippy bounce (Fixes #406) --- src/game/d2map.ts | 6 + src/game/engine.ts | 119 +++- src/game/ground-items.ts | 218 +++++-- src/game/iso-terrain.ts | 9 + src/scene/act-scene.ts | 137 ++--- tests/ground-items-drop.test.ts | 10 +- tests/ground-items-scatter-bounce.test.ts | 697 ++++++++++++++++++++++ 7 files changed, 1030 insertions(+), 166 deletions(-) create mode 100644 tests/ground-items-scatter-bounce.test.ts diff --git a/src/game/d2map.ts b/src/game/d2map.ts index a4c6070..b21e1d5 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -143,6 +143,12 @@ export const COLLIDE_MASK_MISSILE = /** Composite mask for Line-of-Sight (LoS) raycasting. */ export const COLLIDE_MASK_LOS = COLLIDE_VISIBLE | COLLIDE_DOOR +/** Composite mask for drop spawning placement (D2Common COLLISION_GetFreeCoordinatesImpl: 0x3E01). */ +export const COLLIDE_MASK_SPAWN = + COLLIDE_WALL | COLLIDE_ITEM | COLLIDE_OBJECT | COLLIDE_DOOR | COLLIDE_NO_PATH | COLLIDE_PET +/** Composite mask for drop spawning Line-of-Sight raycast (nFieldMask = 0x0801). */ +export const COLLIDE_MASK_SPAWN_LOS = + COLLIDE_WALL | COLLIDE_DOOR /** Result of a 1.13c sub-tile `COLLISION_RayTrace` check. */ export interface RayTraceResult { diff --git a/src/game/engine.ts b/src/game/engine.ts index e113070..6250b26 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -16,6 +16,7 @@ import { MonsterStreamingManager } from './monster-streaming.ts' import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' import { GroundItemManager, triggerFlippyBounce, findIsometricDropPosition } from './ground-items.ts' import type { GroundItemEntity, GroundItemSource } from './ground-items.ts' +import { ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT } from './d2map.ts' export interface EngineInput { readonly movement: { readonly x: number; readonly y: number } @@ -30,8 +31,35 @@ export interface EngineInput { export interface WorldMapProvider { readonly widthPx: number readonly heightPx: number + readonly grid?: any + readonly originX?: number + readonly originY?: number overlap(x: number, y: number): number isMissileBlocked?(x: number, y: number, fromX?: number, fromY?: number): boolean + hasLineOfSight?(fromX: number, fromY: number, toX: number, toY: number): boolean +} + +/** + * Project world pixel coordinates back to map cell coordinates. + * + * Inverts the 2:1 isometric diamond projection: + * px = worldX - originX, py = worldY - originY + * cellX = floor((px / 80 + py / 40) / 2) + * cellY = floor((py / 40 - px / 80) / 2) + */ +export function worldToCell( + terrain: WorldMapProvider | undefined, + worldX: number, + worldY: number, +): { cellX: number; cellY: number } { + const originX = (terrain as any)?.originX ?? (terrain as any)?.grid?.originX ?? 0 + const originY = (terrain as any)?.originY ?? (terrain as any)?.grid?.originY ?? 0 + const px = worldX - originX + const py = worldY - originY + return { + cellX: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2), + cellY: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2), + } } /** @@ -934,17 +962,26 @@ export class GameEngine { }) this.metrics.dropsRolled += 1 + const burstOccupied: { x: number; y: number }[] = [...this.groundItems.all] + const isBlocked = this.terrain ?? undefined + const currentSimTime = this.world.tick * 40 + for (const drop of droppedItems) { - if (drop.base.id === 'gold' || drop.code?.trim() === 'gld') { - this.bag.add(drop) - } else { - const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) - const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) - this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) - this.groundItems.add(drop, 0, 0, dropPos.x, dropPos.y, { bounce: true }) - this.metrics.lastDrops.push(drop.name) - if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() - } + const dropPos = findIsometricDropPosition(event.x, event.y, burstOccupied, isBlocked) + burstOccupied.push(dropPos) + const cellPos = worldToCell(this.terrain, dropPos.x, dropPos.y) + + const isGold = drop.base?.id === 'gold' || drop.code?.trim() === 'gld' || (drop as any).id === 'gold' + const goldAmount = isGold ? (drop.stack ?? drop.value ?? 1) : undefined + + this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop }) + this.groundItems.add(drop, cellPos.cellX, cellPos.cellY, dropPos.x, dropPos.y, { + ...(goldAmount !== undefined ? { amount: goldAmount } : {}), + bounce: true, + now: currentSimTime, + }) + this.metrics.lastDrops.push(drop.name) + if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } } else if (this.opts.itemBases && this.opts.itemBases.length > 0) { const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes ?? [], this.opts.suffixAffixes ?? [], this.loot, { @@ -954,13 +991,27 @@ export class GameEngine { goldRange: [5, 40], }) this.metrics.dropsRolled += 1 + const isBlocked = this.terrain ?? undefined + const currentSimTime = this.world.tick * 40 + const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) + const cellPos = worldToCell(this.terrain, dropPos.x, dropPos.y) + if (drop.kind === 'gold') { - this.bag.add(goldItem(drop.amount)) + const item = goldItem(drop.amount) + this.ground.push({ x: dropPos.x, y: dropPos.y, item }) + this.groundItems.add(item, cellPos.cellX, cellPos.cellY, dropPos.x, dropPos.y, { + amount: drop.amount, + bounce: true, + now: currentSimTime, + }) + this.metrics.lastDrops.push(item.name) + if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } else if (drop.kind === 'item') { - const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) - const dropPos = findIsometricDropPosition(event.x, event.y, this.groundItems.all, isBlocked) this.ground.push({ x: dropPos.x, y: dropPos.y, item: drop.item }) - this.groundItems.add(drop.item, 0, 0, dropPos.x, dropPos.y, { bounce: true }) + this.groundItems.add(drop.item, cellPos.cellX, cellPos.cellY, dropPos.x, dropPos.y, { + bounce: true, + now: currentSimTime, + }) this.metrics.lastDrops.push(drop.item.name) if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() } @@ -1073,14 +1124,14 @@ export class GameEngine { dropItem(item: GroundItemSource, x?: number, y?: number, cellX?: number, cellY?: number): GroundItemEntity { const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y - const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const isBlocked = this.terrain ?? undefined const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) - const worldX = dropPos.x - const worldY = dropPos.y - const cx = cellX ?? 0 - const cy = cellY ?? 0 - const entity = this.groundItems.add(item, cx, cy, worldX, worldY, { bounce: true }) - this.ground.push({ x: worldX, y: worldY, item: item as any }) + const cellPos = worldToCell(this.terrain, dropPos.x, dropPos.y) + const cx = cellX ?? cellPos.cellX + const cy = cellY ?? cellPos.cellY + const now = this.world.tick * 40 + const entity = this.groundItems.add(item, cx, cy, dropPos.x, dropPos.y, { bounce: true, now }) + this.ground.push({ x: dropPos.x, y: dropPos.y, item: item as any }) return entity } @@ -1091,24 +1142,25 @@ export class GameEngine { if (amount <= 0) return null const rawX = x ?? this.world.player.x const rawY = y ?? this.world.player.y - const isBlocked = (bx: number, by: number) => (this.terrain ? this.terrain.overlap(bx, by) !== 0 : false) + const isBlocked = this.terrain ?? undefined const dropPos = findIsometricDropPosition(rawX, rawY, this.groundItems.all, isBlocked) - const worldX = dropPos.x - const worldY = dropPos.y - const cx = cellX ?? 0 - const cy = cellY ?? 0 + const cellPos = worldToCell(this.terrain, dropPos.x, dropPos.y) + const cx = cellX ?? cellPos.cellX + const cy = cellY ?? cellPos.cellY + const now = this.world.tick * 40 + const goldPayload = goldItem(amount) const entity = this.groundItems.add( - { isGold: true, code: 'gld', name: `${amount} Gold`, nameZh: `${amount} 金币` }, + goldPayload, cx, cy, - worldX, - worldY, - { amount, bounce: true }, + dropPos.x, + dropPos.y, + { amount, bounce: true, now }, ) this.ground.push({ - x: worldX, - y: worldY, - item: { name: `${amount} 金币`, stack: amount, value: amount, base: { id: 'gold', name: 'Gold' } as any } as any, + x: dropPos.x, + y: dropPos.y, + item: goldPayload as any, }) return entity } @@ -1127,6 +1179,7 @@ export class GameEngine { if (gIdx !== -1) { this.ground.splice(gIdx, 1) } + this.bag.add(goldItem(amount)) this.metrics.pickups += 1 return { success: true, amount } } diff --git a/src/game/ground-items.ts b/src/game/ground-items.ts index 7847749..a4a2f9a 100644 --- a/src/game/ground-items.ts +++ b/src/game/ground-items.ts @@ -17,9 +17,17 @@ import { COLLIDE_BLANK, COLLIDE_MASK_INVALID, COLLIDE_NONE, + COLLIDE_ITEM, + COLLIDE_MASK_SPAWN, + COLLIDE_MASK_SPAWN_LOS, ORTHO_CELL_WIDTH, ORTHO_CELL_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + getCollisionMaskAt, + rayTraceScene, } from './d2map.ts' +import type { CollisionGrid } from './d2map.ts' export interface GroundItemBounceState { startTime: number @@ -243,82 +251,170 @@ export function findSafeDropPosition( } } +/** + * Helper to check whether a candidate world position is blocked by collision or Line-of-Sight. + */ +export function isDropPositionBlocked( + candX: number, + candY: number, + originX: number, + originY: number, + isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any, +): boolean { + if (!isBlocked) return false + + // 1. If it's a CollisionGrid or has a collision grid + const grid: CollisionGrid | undefined = + isBlocked.collisionMasks !== undefined || isBlocked.gridWidth !== undefined + ? isBlocked + : isBlocked.grid?.collisionMasks !== undefined || isBlocked.grid?.gridWidth !== undefined + ? isBlocked.grid + : undefined + + if (grid !== undefined) { + const mask = getCollisionMaskAt(grid, candX, candY) + if ((mask & COLLIDE_MASK_SPAWN) !== 0) { + return true + } + const los = rayTraceScene(grid, originX, originY, candX, candY, COLLIDE_MASK_SPAWN_LOS) + if (los.hit) { + return true + } + } + + // 2. CountingTerrain overlap check + if (typeof isBlocked.overlap === 'function') { + if (isBlocked.overlap(candX, candY) !== 0) { + return true + } + } + + // 3. CountingTerrain LoS check + if (typeof isBlocked.hasLineOfSight === 'function') { + if (!isBlocked.hasLineOfSight(originX, originY, candX, candY)) { + return true + } + } + + // 4. Function callback (e.g. (x, y) => boolean) + if (typeof isBlocked === 'function') { + if (isBlocked(candX, candY)) { + return true + } + } + + // 5. General isBlocked method + if (typeof isBlocked.isBlocked === 'function') { + if (isBlocked.isBlocked(candX, candY)) { + return true + } + } + + return false +} + +/** + * Generate candidate offsets on concentric ring R around origin in integer sub-tile space, + * minimizing Manhattan distance L1 = |dx| + |dy| with authentic Blizzard tie-breaking. + * Matches D2Common.dll COLLISION_GetFreeCoordinatesImpl (0x6FD9D140). + */ +export function getIsometricRingCandidates(R: number): { dx: number; dy: number; l1: number }[] { + if (R === 0) return [{ dx: 0, dy: 0, l1: 0 }] + const cands: { dx: number; dy: number; l1: number; order: number }[] = [] + let order = 0 + + // Phase 1: Left and right vertical columns (scan dy with smaller |dy| first for L1 minimization) + const dyOrder: number[] = [0] + for (let d = 1; d <= R; d += 1) { + dyOrder.push(-d, d) + } + + for (const dy of dyOrder) { + cands.push({ dx: -R, dy, l1: R + Math.abs(dy), order: order++ }) + cands.push({ dx: R, dy, l1: R + Math.abs(dy), order: order++ }) + } + + // Phase 2: Top and bottom horizontal rows (excluding corners already checked) + const dxOrder: number[] = [0] + for (let d = 1; d < R; d += 1) { + dxOrder.push(d, -d) + } + for (const dx of dxOrder) { + cands.push({ dx, dy: -R, l1: Math.abs(dx) + R, order: order++ }) + cands.push({ dx, dy: R, l1: Math.abs(dx) + R, order: order++ }) + } + + // Sort candidates by L1 Manhattan distance ascending, preserving canonical traversal order for ties + cands.sort((a, b) => { + if (a.l1 !== b.l1) return a.l1 - b.l1 + return a.order - b.order + }) + + return cands +} + /** * Find an unoccupied isometric grid slot around an origin coordinate. * - * In Diablo II v1.13c (D2Common.dll ITEM_Drop 0x6FD40000), items dropped - * on the ground never stack at the exact same pixel coordinates. - * Instead, they disperse across the 2:1 isometric diamond grid lattice. + * Implements Blizzard Diablo II v1.13c 2:1 isometric diamond grid scatter algorithm: + * - Sub-tile steps: stepX = 16px, stepY = 8px (2:1 aspect ratio). + * - Concentric square rings R = 0, 1, 2, ... up to maxRings. + * - L1 Manhattan distance minimization (|dx| + |dy|). + * - Collision checks against COLLIDE_MASK_SPAWN (0x3E01) and LoS raycast against COLLIDE_MASK_SPAWN_LOS (0x0801). + * - Prevents item stacking by enforcing minimum spacing (default 16px). * * @param originX - epicenter X in world coordinates. * @param originY - epicenter Y in world coordinates. * @param existingItems - array of existing ground items to avoid overlapping with. - * @param isBlocked - optional collision callback (returns true if world coordinates are blocked). - * @param stepX - isometric horizontal lattice step (default ORTHO_CELL_WIDTH = 24). - * @param stepY - isometric vertical lattice step (default ORTHO_CELL_HEIGHT = 12). - * @param maxRings - max search rings (default 5). - * @param minSpacing - minimum distance in pixels between ground items (default 20). + * @param isBlocked - optional collision callback or terrain / collision grid. + * @param stepX - isometric horizontal lattice step (default ORTHO_SUB_TILE_WIDTH = 16). + * @param stepY - isometric vertical lattice step (default ORTHO_SUB_TILE_HEIGHT = 8). + * @param maxRings - max search rings (default 10). + * @param minSpacing - minimum distance in pixels between ground items (default 16). */ export function findIsometricDropPosition( originX: number, originY: number, existingItems: readonly { x: number; y: number }[] = [], - isBlocked?: (x: number, y: number) => boolean, - stepX = ORTHO_CELL_WIDTH, - stepY = ORTHO_CELL_HEIGHT, - maxRings = 5, - minSpacing = 20, + isBlocked?: ((x: number, y: number) => boolean) | CollisionGrid | any, + stepX = ORTHO_SUB_TILE_WIDTH, + stepY = ORTHO_SUB_TILE_HEIGHT, + maxRings = 10, + minSpacing = 16, ): { x: number; y: number } { - const candidates: { x: number; y: number; distSq: number }[] = [] - for (let di = -maxRings; di <= maxRings; di += 1) { - for (let dj = -maxRings; dj <= maxRings; dj += 1) { - const cx = originX + (di - dj) * stepX - const cy = originY + (di + dj) * stepY - const distSq = (cx - originX) * (cx - originX) + (cy - originY) * (cy - originY) - candidates.push({ x: cx, y: cy, distSq }) - } - } - - // Sort by Euclidean distance from origin ascending - candidates.sort((a, b) => a.distSq - b.distSq) - const minSpacingSq = minSpacing * minSpacing - let bestCandidate: { x: number; y: number } | null = null - let maxMinDistSq = -1 - for (const cand of candidates) { - if (isBlocked && isBlocked(cand.x, cand.y)) { - continue - } + for (let ring = 0; ring <= maxRings; ring += 1) { + const ringCandidates = getIsometricRingCandidates(ring) - let nearestItemDistSq = Infinity - let hasConflict = false + for (const cand of ringCandidates) { + // 2:1 Isometric projection screen offset: + // cx = originX + (dx - dy) * stepX + // cy = originY + (dx + dy) * stepY + const cx = originX + (cand.dx - cand.dy) * stepX + const cy = originY + (cand.dx + cand.dy) * stepY - for (const item of existingItems) { - const dx = item.x - cand.x - const dy = item.y - cand.y - const dSq = dx * dx + dy * dy - if (dSq < nearestItemDistSq) { - nearestItemDistSq = dSq + // Check collision and line of sight + if (isDropPositionBlocked(cx, cy, originX, originY, isBlocked)) { + continue } - if (dSq < minSpacingSq) { - hasConflict = true - break + + // Check conflict with existing items + let hasConflict = false + for (const item of existingItems) { + const dx = item.x - cx + const dy = item.y - cy + const dSq = dx * dx + dy * dy + if (dSq < minSpacingSq) { + hasConflict = true + break + } + } + + if (!hasConflict) { + return { x: cx, y: cy } } } - - if (!hasConflict) { - return { x: cand.x, y: cand.y } - } - - if (nearestItemDistSq > maxMinDistSq) { - maxMinDistSq = nearestItemDistSq - bestCandidate = cand - } - } - - if (bestCandidate !== null) { - return { x: bestCandidate.x, y: bestCandidate.y } } return { x: originX, y: originY } @@ -349,11 +445,11 @@ export class GroundItemManager { worldX: number, worldY: number, options?: { - amount?: number - bounce?: boolean - peakHeight?: number - durationMs?: number - now?: number + amount?: number | undefined + bounce?: boolean | undefined + peakHeight?: number | undefined + durationMs?: number | undefined + now?: number | undefined }, ): GroundItemEntity { const id = `ground_item_${Date.now()}_${this.idCounter++}` diff --git a/src/game/iso-terrain.ts b/src/game/iso-terrain.ts index c03edff..c11a005 100644 --- a/src/game/iso-terrain.ts +++ b/src/game/iso-terrain.ts @@ -116,6 +116,12 @@ export interface CountingTerrain { readonly widthPx: number /** Level height in screen pixels. */ readonly heightPx: number + /** The underlying collision grid if available. */ + readonly grid?: CollisionGrid + /** Origin X coordinate of map grid. */ + readonly originX?: number + /** Origin Y coordinate of map grid. */ + readonly originY?: number /** * Blocked sample count for a body centred here. * @@ -153,6 +159,9 @@ export function createIsoTerrain( return { widthPx, heightPx, + grid, + originX: grid.originX, + originY: grid.originY, overlap: (x: number, y: number): number => isoOverlap(grid, x, y, body.width, body.height), isMissileBlocked: (x: number, y: number, fromX?: number, fromY?: number): boolean => { if (fromX !== undefined && fromY !== undefined) { diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 110cde6..1b93ebf 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -5187,81 +5187,84 @@ export function drawGroundItem( atlas: itemsAtlas, }) } - return - } - - // 2. Fallback for mock renderer or headless unit tests without atlas - if (item.isGold) { - let w = 10 - let h = 8 - if (item.amount >= 5000) { - w = 18 - h = 13 - } else if (item.amount >= 100) { - w = 14 - h = 10 - } - - // Shadow on ground: stays on ground plane (item.y - 2), alpha fades slightly with height - const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.35 * (1 - bounceH / 45)) : 0.35 - renderer.drawSolid(item.x - w / 2 - 1, item.y - 2, w + 2, 4, [0, 0, 0, shadowAlpha]) - - // Gold coin pile body - renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [0.92, 0.75, 0.18, 1.0]) - // Highlight glint coin rim - renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0]) } else { - const w = item.invWidth > 1 ? 22 : 14 - const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14 + // 2. Fallback for mock renderer or headless unit tests without atlas + if (item.isGold) { + let w = 10 + let h = 8 + if (item.amount >= 5000) { + w = 18 + h = 13 + } else if (item.amount >= 100) { + w = 14 + h = 10 + } - // Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height - const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38 - renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha]) + // Shadow on ground: stays on ground plane (item.y - 2), alpha fades slightly with height + const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.35 * (1 - bounceH / 45)) : 0.35 + renderer.drawSolid(item.x - w / 2 - 1, item.y - 2, w + 2, 4, [0, 0, 0, shadowAlpha]) - // Authentic flippy tumble width modulation during vertical bounce - let renderW = w - if (bounceH > 0 && item.bounceState) { - const elapsed = now - item.bounceState.startTime - // Spin oscillation creating flipping card / tumbling item illusion - const spinScale = Math.abs(Math.cos(elapsed * 0.018)) - renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale))) + // Gold coin pile body + renderer.drawSolid(item.x - w / 2, baseY - h, w, h, [0.92, 0.75, 0.18, 1.0]) + // Highlight glint coin rim + renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0]) + } else { + const w = item.invWidth > 1 ? 22 : 14 + const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14 + + // Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height + const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38 + renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha]) + + // Authentic flippy tumble width modulation during vertical bounce + let renderW = w + if (bounceH > 0 && item.bounceState) { + const elapsed = now - item.bounceState.startTime + // Spin oscillation creating flipping card / tumbling item illusion + const spinScale = Math.abs(Math.cos(elapsed * 0.018)) + renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale))) + } + + // Body + const qr = qColor[0] + const qg = qColor[1] + const qb = qColor[2] + renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9]) + // Border rim + renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) + renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) } - - // Body - const qr = qColor[0] - const qg = qColor[1] - const qb = qColor[2] - renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9]) - // Border rim - renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95]) - renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95]) - renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) - renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95]) } - // 3. Glint / Sparkle 4-Point Star Animation for fallback headless tests - const period = 2000 - const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period - const cycleTime = (now + phaseOffset) % period - const flashDuration = 320 + // 3. Glint / Sparkle 4-Point Star Animation for resting items + // Dropped items display periodic sparkle glints once settled on the ground plane + const isAirborne = item.bounceState !== null && item.bounceState !== undefined && bounceH > 0 + if (!isAirborne) { + const period = 2000 + const phaseOffset = Math.abs(Math.floor(item.cellX * 31 + item.cellY * 17)) % period + const cycleTime = (now + phaseOffset) % period + const flashDuration = 320 - if (cycleTime < flashDuration) { - const p = cycleTime / flashDuration - // Sine bell curve for smooth brightening and fading - const intensity = Math.sin(p * Math.PI) - if (intensity > 0.05) { - const gx = item.x + (item.invWidth > 1 ? 3 : 0) - const gy = baseY - (item.isGold ? 6 : 12) - const rayLen = Math.round(3 + 6 * intensity) + if (cycleTime < flashDuration) { + const p = cycleTime / flashDuration + // Sine bell curve for smooth brightening and fading + const intensity = Math.sin(p * Math.PI) + if (intensity > 0.05) { + const gx = item.x + (item.invWidth > 1 ? 3 : 0) + const gy = baseY - (item.isGold ? 6 : 12) + const rayLen = Math.round(3 + 6 * intensity) - // Horizontal ray - renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9]) - // Vertical ray - renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9]) - // Brilliant central core - renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity]) - // Subtle quality aura - renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35]) + // Horizontal ray + renderer.drawSolid(gx - rayLen, gy - 1, rayLen * 2 + 1, 2, [1.0, 1.0, 1.0, intensity * 0.9]) + // Vertical ray + renderer.drawSolid(gx - 1, gy - rayLen, 2, rayLen * 2 + 1, [1.0, 1.0, 1.0, intensity * 0.9]) + // Brilliant central core + renderer.drawSolid(gx - 2, gy - 2, 4, 4, [1.0, 1.0, 0.92, intensity]) + // Subtle quality aura + renderer.drawSolid(gx - 4, gy - 4, 8, 8, [qColor[0], qColor[1], qColor[2], intensity * 0.35]) + } } } } diff --git a/tests/ground-items-drop.test.ts b/tests/ground-items-drop.test.ts index 2a1b06b..c3c905f 100644 --- a/tests/ground-items-drop.test.ts +++ b/tests/ground-items-drop.test.ts @@ -386,7 +386,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { for (let i = 0; i < items.length; i++) { for (let j = i + 1; j < items.length; j++) { const dist = Math.hypot(items[i]!.x - items[j]!.x, items[i]!.y - items[j]!.y) - expect(dist).toBeGreaterThanOrEqual(20) + expect(dist).toBeGreaterThanOrEqual(16) } } }) @@ -402,7 +402,7 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { const pos = findIsometricDropPosition(400, 400, existing, isBlocked) expect(pos.x === 400 && pos.y === 424).toBe(false) expect(isBlocked(pos.x, pos.y)).toBe(false) - expect(Math.hypot(pos.x - 400, pos.y - 400)).toBeGreaterThanOrEqual(20) + expect(Math.hypot(pos.x - 400, pos.y - 400)).toBeGreaterThanOrEqual(16) }) it('GameEngine.dropItem repeatedly spreads items out across isometric lattice without stacking', () => { @@ -439,9 +439,9 @@ describe('Issue #388 — Ground Item & Drop System Ground Truth', () => { // d2 and d3 must be dispersed and not stacked at (200, 200) expect(d2.x !== 200 || d2.y !== 200).toBe(true) expect(d3.x !== 200 || d3.y !== 200).toBe(true) - expect(Math.hypot(d2.x - d1.x, d2.y - d1.y)).toBeGreaterThanOrEqual(20) - expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(20) - expect(Math.hypot(d3.x - d2.x, d3.y - d2.y)).toBeGreaterThanOrEqual(20) + expect(Math.hypot(d2.x - d1.x, d2.y - d1.y)).toBeGreaterThanOrEqual(16) + expect(Math.hypot(d3.x - d1.x, d3.y - d1.y)).toBeGreaterThanOrEqual(16) + expect(Math.hypot(d3.x - d2.x, d3.y - d2.y)).toBeGreaterThanOrEqual(16) }) }) }) diff --git a/tests/ground-items-scatter-bounce.test.ts b/tests/ground-items-scatter-bounce.test.ts new file mode 100644 index 0000000..0a6a0dd --- /dev/null +++ b/tests/ground-items-scatter-bounce.test.ts @@ -0,0 +1,697 @@ +import { describe, it, expect } from 'vitest' +import { + GroundItemManager, + findIsometricDropPosition, + getIsometricRingCandidates, + isDropPositionBlocked, + calculateBounceHeight, + triggerFlippyBounce, +} from '../src/game/ground-items.ts' +import type { GroundItemEntity } from '../src/game/ground-items.ts' +import { + GameEngine, + worldToCell, +} from '../src/game/engine.ts' +import type { GameEngineOptions, WorldMapProvider } from '../src/game/engine.ts' +import { + COLLIDE_NONE, + COLLIDE_WALL, + COLLIDE_ITEM, + COLLIDE_OBJECT, + COLLIDE_DOOR, + COLLIDE_NO_PATH, + COLLIDE_PET, + COLLIDE_MASK_SPAWN, + COLLIDE_MASK_SPAWN_LOS, + ORTHO_CELL_WIDTH, + ORTHO_CELL_HEIGHT, + ORTHO_SUB_TILE_WIDTH, + ORTHO_SUB_TILE_HEIGHT, + subTileAt, +} from '../src/game/d2map.ts' +import type { CollisionGrid } from '../src/game/d2map.ts' +import { drawGroundItem } from '../src/scene/act-scene.ts' +import { resolveGroundItemSpriteRect } from '../src/ui/inventory.ts' +import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts' +import type { SpriteRenderer } from '../src/render/renderer.ts' +import { DEMO_SKILLS, DEMO_EXPERIENCE } from '../src/game/demo-data.ts' + +function createDummyTerrain(widthPx = 2000, heightPx = 2000, originX = 0, originY = 0): WorldMapProvider { + return { + widthPx, + heightPx, + originX, + originY, + overlap: () => 0, + } +} + +function createMockRenderer() { + const drawnQuads: { + x: number + y: number + w: number + h: number + color: readonly [number, number, number, number] + }[] = [] + const drawnSprites: { frame: any; x: number; y: number; options: any }[] = [] + + const renderer: SpriteRenderer = { + drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) { + drawnQuads.push({ x, y, w, h, color }) + }, + draw(frame: any, x: number, y: number, options: any) { + drawnSprites.push({ frame, x, y, options }) + }, + } as unknown as SpriteRenderer + + return { renderer, drawnQuads, drawnSprites } +} + +describe('Milestone M11.3 (Issue #406) — Ground Item & Gold Spawning, Isometric Scatter & Flippy Bounce', () => { + describe('1. Monster Loot & Gold Ground Spawning (Elimination of Silent Bag Addition)', () => { + it('monster gold drops spawn as GroundItemEntity on the ground and do NOT silently add to player bag', () => { + const terrain = createDummyTerrain() + const engineOpts: GameEngineOptions = { + spawn: { x: 500, y: 500 }, + stats: [ + { + id: 'monster1', + name: 'Zombie', + level: 3, + hp: 1, // 1 hp so one hit kills + damage: 0, + cooldownTicks: 10, + reach: 30, + aggroRadius: 100, + speed: 0, + xp: 10, + }, + ], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + itemBases: [ + { id: 'gold', name: 'Gold', invWidth: 1, invHeight: 1, code: 'gld' } as any, + ], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 1, + monsterSpread: 10, + } + + const engine = new GameEngine(terrain, engineOpts) + + // Initial state: bag has 0 gold, 0 ground items + expect(engine.bag.gold).toBe(0) + expect(engine.groundItems.count).toBe(0) + expect(engine.ground.length).toBe(0) + + // Trigger kill via attacking + let killed = false + for (let tick = 0; tick < 20; tick++) { + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: true, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, + }) + if (engine.world.events.some(e => e.kind === 'kill')) { + killed = true + break + } + } + + expect(killed).toBe(true) + + // If gold or items dropped, they MUST be on the ground, NEVER siphoned into bag! + if (engine.groundItems.count > 0) { + const goldDrops = engine.groundItems.all.filter(g => g.isGold) + if (goldDrops.length > 0) { + // Strict 1.13c parity: Gold is on the floor! + expect(engine.bag.gold).toBe(0) + for (const g of goldDrops) { + expect(g.isGold).toBe(true) + expect(g.amount).toBeGreaterThan(0) + expect(g.name).toContain('Gold') + expect(g.nameZh).toContain('金币') + expect(g.bounceState).toBeDefined() + expect(g.bounceState?.phase).toBe('primary') + } + } + } + }) + + it('pickupGold collects gold from ground and correctly synchronizes player bag gold', () => { + const terrain = createDummyTerrain() + const engine = new GameEngine(terrain, { + spawn: { x: 500, y: 500 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }) + + // Drop 2500 gold + const goldEntity = engine.dropGold(2500, 520, 520) + expect(goldEntity).toBeDefined() + expect(engine.groundItems.count).toBe(1) + expect(engine.ground.length).toBe(1) + expect(engine.bag.gold).toBe(0) + + // Pickup gold via ID + const result = engine.pickupGold(goldEntity!.id) + expect(result.success).toBe(true) + expect(result.amount).toBe(2500) + expect(engine.bag.gold).toBe(2500) + expect(engine.groundItems.count).toBe(0) + expect(engine.ground.length).toBe(0) + expect(engine.metrics.pickups).toBe(1) + }) + + it('pickupItem with gold entity delegates to pickupGold and updates bag', () => { + const terrain = createDummyTerrain() + const engine = new GameEngine(terrain, { + spawn: { x: 500, y: 500 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }) + + const goldEntity = engine.dropGold(1200, 510, 510) + const res = engine.pickupItem(goldEntity!.id) + expect(res.success).toBe(true) + expect(engine.bag.gold).toBe(1200) + expect(engine.groundItems.count).toBe(0) + }) + + it('walking over gold with pickingUp=true adds gold to bag and removes ground item', () => { + const terrain = createDummyTerrain() + const engine = new GameEngine(terrain, { + spawn: { x: 500, y: 500 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 40, + inventoryCols: 10, + inventoryRows: 4, + }) + + // Drop gold 10px away from player + engine.dropGold(750, 505, 505) + expect(engine.bag.gold).toBe(0) + + // Player picks up + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: true, + talking: false, + digits: [], + saving: false, + loading: false, + }) + + expect(engine.bag.gold).toBe(750) + expect(engine.groundItems.count).toBe(0) + expect(engine.ground.length).toBe(0) + }) + }) + + describe('2. Cell Coordinate Inversion Synchronization (worldToCell)', () => { + it('accurately converts world coordinates to cell coordinates under 2:1 isometric projection', () => { + const terrain: WorldMapProvider = { + widthPx: 5000, + heightPx: 5000, + originX: 1000, + originY: 500, + overlap: () => 0, + } + + // origin cell (0, 0) + const c0 = worldToCell(terrain, 1000, 500) + expect(c0.cellX).toBe(0) + expect(c0.cellY).toBe(0) + + // Cell (1, 0): worldX = 1000 + (1 - 0) * 80 = 1080, worldY = 500 + (1 + 0) * 40 = 540 + const c10 = worldToCell(terrain, 1080, 540) + expect(c10.cellX).toBe(1) + expect(c10.cellY).toBe(0) + + // Cell (0, 1): worldX = 1000 + (0 - 1) * 80 = 920, worldY = 500 + (0 + 1) * 40 = 540 + const c01 = worldToCell(terrain, 920, 540) + expect(c01.cellX).toBe(0) + expect(c01.cellY).toBe(1) + + // Cell (5, 8): + // worldX = 1000 + (5 - 8) * 80 = 760 + // worldY = 500 + (5 + 8) * 40 = 1020 + const c58 = worldToCell(terrain, 760, 1020) + expect(c58.cellX).toBe(5) + expect(c58.cellY).toBe(8) + }) + + it('GameEngine.dropItem and dropGold assign actual cellX/cellY rather than hardcoding (0, 0)', () => { + const terrain: WorldMapProvider = { + widthPx: 5000, + heightPx: 5000, + originX: 0, + originY: 0, + overlap: () => 0, + } + const engine = new GameEngine(terrain, { + spawn: { x: 800, y: 400 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }) + + const item = engine.dropItem({ name: 'Long Bow' } as any, 800, 400) + // (800, 400) with origin (0, 0): px = 800, py = 400 + // cellX = floor((800/80 + 400/40) / 2) = floor((10 + 10) / 2) = 10 + // cellY = floor((400/40 - 800/80) / 2) = floor((10 - 10) / 2) = 0 + expect(item.cellX).toBe(10) + expect(item.cellY).toBe(0) + expect(item.cellX !== 0 || item.cellY !== 0).toBe(true) + + const gold = engine.dropGold(100, 160, 240) + // (160, 240): px = 160, py = 240 + // cellX = floor((160/80 + 240/40) / 2) = floor((2 + 6) / 2) = 4 + // cellY = floor((240/40 - 160/80) / 2) = floor((6 - 2) / 2) = 2 + expect(gold!.cellX).toBe(4) + expect(gold!.cellY).toBe(2) + }) + + it('items dropped at distinct cell coordinates have desynchronized sparkle phase offsets', () => { + const period = 2000 + const getPhase = (cellX: number, cellY: number) => + Math.abs(Math.floor(cellX * 31 + cellY * 17)) % period + + const phase1 = getPhase(10, 5) + const phase2 = getPhase(11, 5) + const phase3 = getPhase(10, 6) + + expect(phase1).not.toBe(phase2) + expect(phase1).not.toBe(phase3) + expect(phase2).not.toBe(phase3) + }) + }) + + describe('3. Clock Domain Synchronization & Parabolic Flippy Bounce Kinematics', () => { + it('GameEngine drops synchronize bounce startTime with simulation clock (world.tick * 40)', () => { + const terrain = createDummyTerrain() + const engine = new GameEngine(terrain, { + spawn: { x: 500, y: 500 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: [], + combatOptions: { + playerSpeed: 10, + playerReach: 50, + playerCooldownTicks: 1, + playerDamage: 10, + playerManaPerAttack: 0, + respawnTicks: 1000, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + }) + + // Advance engine by 25 ticks (tick = 25, sim time = 1000ms) + for (let i = 0; i < 25; i++) { + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, + }) + } + + expect(engine.world.tick).toBe(25) + + const dropped = engine.dropItem({ name: 'Short Sword' } as any, 500, 500) + expect(dropped.bounceState).toBeDefined() + // startTime must match simulation clock 25 * 40 = 1000, NOT performance.now() + expect(dropped.bounceState!.startTime).toBe(1000) + expect(dropped.dropTime).toBe(1000) + }) + + it('2-phase bounce decay advances in GameEngine simulation and transitions cleanly to resting state', () => { + const manager = new GroundItemManager() + const entity = manager.add({ name: 'Helm' }, 2, 3, 200, 200, { + bounce: true, + durationMs: 400, + peakHeight: 24, + now: 0, // sim time 0 + }) + + expect(entity.bounceState?.phase).toBe('primary') + expect(entity.bounceState?.peakHeightPx).toBe(24) + + // Apex of primary bounce (t = 200ms) + expect(calculateBounceHeight(entity.bounceState!, 200)).toBe(24) + + // Advance to 400ms (end of primary bounce): transitions to secondary bounce + manager.update(400, 40) + expect(entity.bounceState?.phase).toBe('secondary') + expect(entity.bounceState?.durationMs).toBe(200) // 50% of 400ms + expect(entity.bounceState?.peakHeightPx).toBe(6) // 25% of 24px + + // Apex of secondary bounce (t = 500ms, p = 0.5 of 200ms) + expect(calculateBounceHeight(entity.bounceState!, 500)).toBe(6) + + // Advance past secondary duration (t = 600ms): transitions to null (resting) + manager.update(600, 40) + expect(entity.bounceState).toBeNull() + expect(manager.getRenderOffset(entity, 600)).toEqual({ x: 0, y: 0 }) + }) + }) + + describe('4. 2:1 Isometric Diamond Grid Scatter Algorithm Parity', () => { + it('produces authentic D2Common 8-slot isometric diamond sequence on 16x8 sub-tile lattice', () => { + const originX = 1000 + const originY = 800 + const existingItems: { x: number; y: number }[] = [] + + // Drop 9 items in sequence at same epicenter + for (let i = 0; i < 9; i++) { + const pos = findIsometricDropPosition(originX, originY, existingItems) + existingItems.push(pos) + } + + expect(existingItems.length).toBe(9) + + // Drop 1: Epicenter (Ring 0) + expect(existingItems[0]).toEqual({ x: 1000, y: 800 }) + + // Ring 1 canonical sequence: + // Drop 2: (-1, 0) -> (-16, -8) + expect(existingItems[1]).toEqual({ x: 1000 - 16, y: 800 - 8 }) + // Drop 3: (1, 0) -> (+16, +8) + expect(existingItems[2]).toEqual({ x: 1000 + 16, y: 800 + 8 }) + // Drop 4: (0, -1) -> (+16, -8) + expect(existingItems[3]).toEqual({ x: 1000 + 16, y: 800 - 8 }) + // Drop 5: (0, 1) -> (-16, +8) + expect(existingItems[4]).toEqual({ x: 1000 - 16, y: 800 + 8 }) + // Drop 6: (-1, -1) -> (0, -16) [Top Apex] + expect(existingItems[5]).toEqual({ x: 1000, y: 800 - 16 }) + // Drop 7: (1, -1) -> (+32, 0) [Right Apex] + expect(existingItems[6]).toEqual({ x: 1000 + 32, y: 800 }) + // Drop 8: (-1, 1) -> (-32, 0) [Left Apex] + expect(existingItems[7]).toEqual({ x: 1000 - 32, y: 800 }) + // Drop 9: (1, 1) -> (0, +16) [Bottom Apex] + expect(existingItems[8]).toEqual({ x: 1000, y: 800 + 16 }) + + // Verify exact 2:1 isometric diamond aspect ratio: + // Horizontal diameter = 32 - (-32) = 64px + // Vertical diameter = 16 - (-16) = 32px + // Ratio = 64 / 32 = 2.0 (exact 2:1!) + const minX = Math.min(...existingItems.map(p => p.x)) + const maxX = Math.max(...existingItems.map(p => p.x)) + const minY = Math.min(...existingItems.map(p => p.y)) + const maxY = Math.max(...existingItems.map(p => p.y)) + + const spanX = maxX - minX + const spanY = maxY - minY + expect(spanX).toBe(64) + expect(spanY).toBe(32) + expect(spanX / spanY).toBe(2) + }) + + it('strictly minimizes L1 Manhattan distance (|dx| + |dy|) within concentric rings', () => { + const ring1Cands = getIsometricRingCandidates(1) + expect(ring1Cands.length).toBe(8) + + // First 4 candidates have L1 = 1 (|dx| + |dy| = 1) + for (let i = 0; i < 4; i++) { + expect(ring1Cands[i]!.l1).toBe(1) + } + // Remaining 4 corner candidates have L1 = 2 + for (let i = 4; i < 8; i++) { + expect(ring1Cands[i]!.l1).toBe(2) + } + + const ring2Cands = getIsometricRingCandidates(2) + expect(ring2Cands.length).toBe(16) + // L1 must be monotonically non-decreasing + for (let i = 1; i < ring2Cands.length; i++) { + expect(ring2Cands[i]!.l1).toBeGreaterThanOrEqual(ring2Cands[i - 1]!.l1) + } + }) + + it('anti-stacking: all items maintain minimum spacing >= 16px', () => { + const items: { x: number; y: number }[] = [] + for (let i = 0; i < 15; i++) { + const pos = findIsometricDropPosition(500, 500, items) + items.push(pos) + } + + for (let i = 0; i < items.length; i++) { + for (let j = i + 1; j < items.length; j++) { + const dist = Math.hypot(items[i]!.x - items[j]!.x, items[i]!.y - items[j]!.y) + expect(dist).toBeGreaterThanOrEqual(16) + } + } + }) + }) + + describe('5. Collision Masks & Line-of-Sight Raycast Protection', () => { + it('rejects candidate sub-tiles with COLLIDE_MASK_SPAWN bits set', () => { + // Create a 10x10 cell grid (50x50 sub-tiles) + const widthSub = 50 + const heightSub = 50 + const collisionMasks = new Uint16Array(widthSub * heightSub) + const mockGrid: CollisionGrid = { + gridWidth: widthSub, + cellsX: 10, + cellsY: 10, + originX: 0, + originY: 0, + blocked: new Uint8Array(widthSub * heightSub), + collisionMasks, + } + + // Origin at (400, 200) + const originX = 400 + const originY = 200 + + // Block candidate 2: (-16, -8) from origin + const cand2X = originX - 16 + const cand2Y = originY - 8 + const cand2Sub = subTileAt(mockGrid, cand2X, cand2Y) + collisionMasks[cand2Sub.subY * widthSub + cand2Sub.subX] = COLLIDE_WALL // 0x0001 (part of COLLIDE_MASK_SPAWN 0x3E01) + + const existing = [{ x: originX, y: originY }] + const pos = findIsometricDropPosition(originX, originY, existing, mockGrid) + + // Candidate 2 is blocked by COLLIDE_WALL, so it skips to Candidate 3 (+16, +8) + expect(pos).not.toEqual({ x: cand2X, y: cand2Y }) + expect(pos).toEqual({ x: originX + 16, y: originY + 8 }) + }) + + it('rejects candidates across walls via COLLIDE_MASK_SPAWN_LOS line-of-sight raycast', () => { + const widthSub = 50 + const heightSub = 50 + const collisionMasks = new Uint16Array(widthSub * heightSub) + const mockGrid: CollisionGrid = { + gridWidth: widthSub, + cellsX: 10, + cellsY: 10, + originX: 0, + originY: 0, + blocked: new Uint8Array(widthSub * heightSub), + collisionMasks, + } + + const originX = 400 + const originY = 200 + + // Place a closed door (COLLIDE_DOOR = 0x0800, in COLLIDE_MASK_SPAWN_LOS) on the ray + // between origin (400, 200) and right apex (432, 200) + const mid = subTileAt(mockGrid, 416, 200) + collisionMasks[mid.subY * widthSub + mid.subX] = COLLIDE_DOOR + + const existing: { x: number; y: number }[] = [ + { x: originX, y: originY }, + { x: originX - 16, y: originY - 8 }, + { x: originX + 16, y: originY + 8 }, + { x: originX + 16, y: originY - 8 }, + { x: originX - 16, y: originY + 8 }, + { x: originX, y: originY - 16 }, + ] + + // Next candidate would naturally be Right Apex (+32, 0) + const pos = findIsometricDropPosition(originX, originY, existing, mockGrid) + // Ray to (+32, 0) hits the door, so Right Apex must be rejected + expect(pos).not.toEqual({ x: originX + 32, y: originY }) + // Drops to Left Apex (-32, 0) or unblocked candidate instead + expect(pos).toEqual({ x: originX - 32, y: originY }) + }) + }) + + describe('6. Rendering & Sprite Selection Parity', () => { + it('drawGroundItem renders sparkle glints on resting items when itemsAtlas is available', () => { + const { renderer, drawnQuads, drawnSprites } = createMockRenderer() + const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any + + const groundSword: GroundItemEntity = { + id: 'sword_resting', + item: { name: 'Short Sword', code: 'ssd' }, + name: 'Short Sword', + nameZh: '短剑', + quality: 'magic', + isGold: false, + amount: 1, + invWidth: 1, + invHeight: 3, + dropTime: 0, + x: 300, + y: 250, + cellX: 0, + cellY: 0, + sparklePhase: 0, + bounceState: null, // resting! + } + + // Render during active flash window (t = 160ms, period = 2000, 160 < 320) + drawGroundItem(renderer, groundSword, 160, mockAtlas) + + // Verified: Sprite is drawn + expect(drawnSprites.length).toBe(1) + expect(drawnSprites[0]!.options.atlas).toBe(mockAtlas) + + // Verified: Drop shadow is drawn + const shadow = drawnQuads.find(q => q.y === 250 - 2 && q.color[3] <= 0.4) + expect(shadow).toBeDefined() + + // Verified: Sparkle star rays and core are drawn even with itemsAtlas present! + const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) + expect(whiteGlint).toBeDefined() + }) + + it('suppresses sparkle glints while item is airborne in bounce trajectory', () => { + const { renderer, drawnQuads } = createMockRenderer() + const mockAtlas = { texture: {} as any, width: 1024, height: 1024 } as any + + const airborneSword: GroundItemEntity = { + id: 'sword_airborne', + item: { name: 'Short Sword', code: 'ssd' }, + name: 'Short Sword', + nameZh: '短剑', + quality: 'magic', + isGold: false, + amount: 1, + invWidth: 1, + invHeight: 3, + dropTime: 0, + x: 300, + y: 250, + cellX: 0, + cellY: 0, + sparklePhase: 0, + bounceState: { + startTime: 0, + durationMs: 400, + peakHeightPx: 24, + phase: 'primary', + }, + } + + // At t = 160ms (apex bounceH > 0), item is mid-air + drawGroundItem(renderer, airborneSword, 160, mockAtlas) + + // While airborne, sparkle rays should NOT be drawn + const whiteGlint = drawnQuads.find(q => q.color[0] === 1.0 && q.color[1] === 1.0 && q.color[2] === 1.0) + expect(whiteGlint).toBeUndefined() + }) + + it('resolves gold pile sprite tier based on amount (<100, 100-499, 500-4999, >=5000)', () => { + const flippyRects = BAKED_UI_MANIFEST.flippyRects + + const small = resolveGroundItemSpriteRect({ isGold: true, amount: 45, code: 'gld' }) + expect(small).toEqual(flippyRects['flpgld_0']) + + const med = resolveGroundItemSpriteRect({ isGold: true, amount: 250, code: 'gld' }) + expect(med).toEqual(flippyRects['flpgld_1']) + + const large = resolveGroundItemSpriteRect({ isGold: true, amount: 2000, code: 'gld' }) + expect(large).toEqual(flippyRects['flpgld_2']) + + const huge = resolveGroundItemSpriteRect({ isGold: true, amount: 15000, code: 'gld' }) + expect(huge).toEqual(flippyRects['flpgld_3']) + }) + }) +})