feat(ground): monster loot and gold ground spawning, isometric scatter, and flippy bounce (Fixes #406)
This commit is contained in:
parent
bfc1164d9b
commit
1568fcbd10
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++}`
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue