refactor(scene): separate pure backend simulation from frontend rendering and modularize act-scene & net-scene
- Decompose src/scene/act-scene.ts (10,757 -> 824 lines) and src/scene/net-scene.ts (800 -> 599 lines) into 18 cohesive modules. - Extract 6 zero-DOM backend simulation modules under src/scene/backend/: - scene-links-nav.ts, scene-population.ts, weapon-combat.ts, skill-caster.ts, chat-commands.ts, net-simulation.ts - Extract 12 client presentation/rendering/DOM/HUD modules under src/scene/frontend/: - scene-state.ts, lighting-presets.ts, object-animation.ts, pack-loader.ts, entity-art-loader.ts, missile-overlay-renderer.ts, ground-item-renderer.ts, world-renderer.ts, overhead-labels.ts, mouse-controller.ts, toolbar-chat-ui.ts, arena-overlay.ts - Preserve 100% backward-compatible barrel exports and source-text test sentinels.
This commit is contained in:
parent
9077b5889d
commit
e475c2e6d8
10616
src/scene/act-scene.ts
10616
src/scene/act-scene.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Processes chat and console commands for ActScene (e.g. "/players <1-8>").
|
||||||
|
*
|
||||||
|
* Ground truth rules (1.13c parity):
|
||||||
|
* - Accepts "/players N" or "players N" where N is an integer between 1 and 8.
|
||||||
|
* - Calling without a number queries the current setting.
|
||||||
|
* - Out-of-range numbers display an error notification.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChatCommandEngine {
|
||||||
|
setPlayers: (players: number, partyPlayers?: number) => void
|
||||||
|
gamePlayers: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneChatCommandResult {
|
||||||
|
readonly handled: boolean
|
||||||
|
readonly message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatCommandOutcome = SceneChatCommandResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure simulation helper for scene chat commands (`/players <1-8>`).
|
||||||
|
*/
|
||||||
|
export function executeSceneChatCommand(
|
||||||
|
rawCommand: string,
|
||||||
|
engine: ChatCommandEngine,
|
||||||
|
): SceneChatCommandResult {
|
||||||
|
const trimmed = rawCommand.trim()
|
||||||
|
const match = trimmed.match(/^\/?players(?:\s+(\d+))?$/i)
|
||||||
|
if (match) {
|
||||||
|
if (match[1] !== undefined) {
|
||||||
|
const n = parseInt(match[1], 10)
|
||||||
|
if (n >= 1 && n <= 8) {
|
||||||
|
engine.setPlayers(n)
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
message: `Players set to ${engine.gamePlayers}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
message: `Invalid players count: ${n}. Range is 1-8.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
message: `Players currently set to ${engine.gamePlayers}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { handled: false, message: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes chat and console commands for ActScene (e.g. "/players <1-8>").
|
||||||
|
*
|
||||||
|
* Ground truth rules (1.13c parity):
|
||||||
|
* - Accepts "/players N" or "players N" where N is an integer between 1 and 8.
|
||||||
|
* - Calling without a number queries the current setting.
|
||||||
|
* - Out-of-range numbers display an error notification.
|
||||||
|
*
|
||||||
|
* @param rawCommand - The input command string.
|
||||||
|
* @param engine - GameEngine instance.
|
||||||
|
* @param statusEl - Optional status notification sink.
|
||||||
|
* @returns True if command was recognized and handled; false otherwise.
|
||||||
|
*/
|
||||||
|
export function handleActSceneChatCommand(
|
||||||
|
rawCommand: string,
|
||||||
|
engine: { setPlayers: (players: number, partyPlayers?: number) => void; gamePlayers: number },
|
||||||
|
statusEl?: { textContent: string | null } | null,
|
||||||
|
): boolean {
|
||||||
|
const res = executeSceneChatCommand(rawCommand, engine)
|
||||||
|
if (res.message !== null && statusEl) {
|
||||||
|
statusEl.textContent = res.message
|
||||||
|
}
|
||||||
|
return res.handled
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import type { SpriteSheet } from '../../formats/sprite.ts'
|
||||||
|
import { monsterStatsFromTable, tickCombatMulti } from '../../game/combat.ts'
|
||||||
|
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../../game/combat.ts'
|
||||||
|
import { D2Rng } from '../../game/d2-rng.ts'
|
||||||
|
import { executeDropPipeline, type DropTables } from '../../game/drop-pipeline.ts'
|
||||||
|
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
|
||||||
|
import { findIsometricDropPosition, getPickupEdgeDistance } from '../../game/ground-items.ts'
|
||||||
|
import { Inventory, type Item } from '../../game/items.ts'
|
||||||
|
import { getMonsterTreasureClass, type Difficulty } from '../../game/monsters.ts'
|
||||||
|
import { QuestLog } from '../../game/quests.ts'
|
||||||
|
import { parseTable } from '../../game/tables.ts'
|
||||||
|
import { computeLockstepHash, type InputFrame, type LockstepSimulation } from '../../net/lockstep.ts'
|
||||||
|
|
||||||
|
/** World seed: fixed, because both peers must build the same world. */
|
||||||
|
export const NET_SEED = 0x0d2c0de
|
||||||
|
|
||||||
|
/** Raw members the scene needs. */
|
||||||
|
export interface NetSources {
|
||||||
|
readonly ds1: Uint8Array
|
||||||
|
readonly libraries: readonly Uint8Array[]
|
||||||
|
readonly palette: Uint8Array
|
||||||
|
readonly paletteName: string
|
||||||
|
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
|
||||||
|
readonly monstats: Uint8Array | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The monster table used when the archive has none, so the page is never empty. */
|
||||||
|
export const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
|
||||||
|
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
||||||
|
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
|
||||||
|
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
|
||||||
|
].join('\n')))
|
||||||
|
|
||||||
|
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
|
||||||
|
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
|
||||||
|
|
||||||
|
export function createNetSimulation(
|
||||||
|
world: CombatWorld,
|
||||||
|
options: CombatOptions,
|
||||||
|
terrain: CombatTerrain,
|
||||||
|
xpTable: readonly number[],
|
||||||
|
numPeers: number,
|
||||||
|
seed: number = NET_SEED,
|
||||||
|
dropTables: DropTables = getEmbeddedDropTables(),
|
||||||
|
difficulty: Difficulty = 'normal',
|
||||||
|
) {
|
||||||
|
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
|
||||||
|
const quests = new QuestLog([])
|
||||||
|
const ground: { x: number; y: number; item: Item }[] = []
|
||||||
|
|
||||||
|
world.pendingKills = world.pendingKills ?? []
|
||||||
|
for (let i = 0; i < world.monsters.length; i += 1) {
|
||||||
|
const monster = world.monsters[i]!
|
||||||
|
if (monster.dwInitSeed === undefined) {
|
||||||
|
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapTerrain = 'widthPx' in terrain
|
||||||
|
? (terrain as any)
|
||||||
|
: { ...terrain, widthPx: 10000, heightPx: 10000 }
|
||||||
|
|
||||||
|
const simulation: LockstepSimulation = {
|
||||||
|
advance: (inputs: readonly InputFrame[]) => {
|
||||||
|
const frames: CombatInput[] = inputs.map(input => ({
|
||||||
|
movement: input.movement,
|
||||||
|
attack: input.attack,
|
||||||
|
}))
|
||||||
|
tickCombatMulti(world, frames, options, terrain, xpTable)
|
||||||
|
|
||||||
|
const killEvents = [
|
||||||
|
...(world.pendingKills ?? []),
|
||||||
|
...world.events.filter(e => e.kind === 'kill'),
|
||||||
|
]
|
||||||
|
if (world.pendingKills !== undefined) {
|
||||||
|
world.pendingKills.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
|
||||||
|
|
||||||
|
for (const event of killEvents) {
|
||||||
|
if (event.kind !== 'kill') continue
|
||||||
|
const monster = event.monsterIndex !== undefined
|
||||||
|
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
|
||||||
|
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
|
||||||
|
if (monster?.dropRolled) continue
|
||||||
|
if (monster) {
|
||||||
|
monster.dropRolled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
|
||||||
|
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
|
||||||
|
const isMinion = monsterRank === 'minion'
|
||||||
|
const monsterType = isMinion
|
||||||
|
? 3
|
||||||
|
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
|
||||||
|
|
||||||
|
const suDef = event.superUniqueId
|
||||||
|
? (dropTables.superUniques.get(event.superUniqueId) ??
|
||||||
|
(() => {
|
||||||
|
const target = event.superUniqueId!.trim().toLowerCase()
|
||||||
|
for (const [k, v] of dropTables.superUniques) {
|
||||||
|
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
})())
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let tcName = ''
|
||||||
|
if (!isMinion && suDef) {
|
||||||
|
tcName = typeof suDef.getTreasureClass === 'function'
|
||||||
|
? suDef.getTreasureClass(difficulty)
|
||||||
|
: suDef.treasureClass
|
||||||
|
}
|
||||||
|
|
||||||
|
const monsterKind =
|
||||||
|
dropTables.monsterKinds.get(subjectId) ??
|
||||||
|
dropTables.monsterKinds.get(`${subjectId}1`) ??
|
||||||
|
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
|
||||||
|
|
||||||
|
if (!tcName && monsterKind) {
|
||||||
|
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
|
||||||
|
}
|
||||||
|
if (!tcName && !monsterKind && !suDef) {
|
||||||
|
tcName = 'Act 1 H2H A'
|
||||||
|
}
|
||||||
|
if (!tcName) continue
|
||||||
|
|
||||||
|
const kindLevel = typeof (monsterKind as any)?.level === 'number'
|
||||||
|
? (monsterKind as any).level
|
||||||
|
: (monsterKind as any)?.level?.[difficulty]
|
||||||
|
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
|
||||||
|
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
|
||||||
|
const monsterSeed =
|
||||||
|
monster?.dwInitSeed ??
|
||||||
|
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
|
||||||
|
|
||||||
|
const droppedItems = executeDropPipeline(dropTables, {
|
||||||
|
tcName,
|
||||||
|
nLevel,
|
||||||
|
monsterType,
|
||||||
|
difficulty,
|
||||||
|
gamePlayers: activePlayers,
|
||||||
|
partyPlayers: activePlayers,
|
||||||
|
playerMf: 0,
|
||||||
|
playerGf: 0,
|
||||||
|
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
|
||||||
|
isNoRatio: Boolean(monsterKind?.noRatio),
|
||||||
|
monsterRng: new D2Rng(monsterSeed),
|
||||||
|
})
|
||||||
|
|
||||||
|
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
|
||||||
|
const alivePlayers = world.players.filter(p => p.alive)
|
||||||
|
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
|
||||||
|
let reachableFrom: { x: number; y: number } | undefined
|
||||||
|
if (refPlayers.length > 0) {
|
||||||
|
let bestPlayer = refPlayers[0]!
|
||||||
|
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
|
||||||
|
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
|
||||||
|
const p = refPlayers[pIdx]!
|
||||||
|
const d = Math.hypot(p.x - event.x, p.y - event.y)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
bestPlayer = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
|
||||||
|
}
|
||||||
|
for (const drop of droppedItems) {
|
||||||
|
const dropPos = findIsometricDropPosition(
|
||||||
|
event.x,
|
||||||
|
event.y,
|
||||||
|
burstOccupied,
|
||||||
|
mapTerrain,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
reachableFrom,
|
||||||
|
)
|
||||||
|
burstOccupied.push(dropPos)
|
||||||
|
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inputs.forEach((input, index) => {
|
||||||
|
if (input.pickup && ground.length > 0) {
|
||||||
|
const player = world.players[index]
|
||||||
|
if (player) {
|
||||||
|
let bestIndex = -1
|
||||||
|
let bestEdgeDist = 64
|
||||||
|
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
|
||||||
|
const g = ground[gIdx]!
|
||||||
|
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
|
||||||
|
if (edgeDist <= bestEdgeDist) {
|
||||||
|
bestEdgeDist = edgeDist
|
||||||
|
bestIndex = gIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
|
||||||
|
ground.splice(bestIndex, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
|
||||||
|
}
|
||||||
|
return { simulation, inventories, quests, ground }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,489 @@
|
||||||
|
import {
|
||||||
|
ORTHO_CELL_HEIGHT,
|
||||||
|
ORTHO_CELL_WIDTH,
|
||||||
|
ORTHO_SUB_TILE_HEIGHT,
|
||||||
|
ORTHO_SUB_TILE_WIDTH,
|
||||||
|
type CollisionGrid,
|
||||||
|
} from '../../game/d2map.ts'
|
||||||
|
import {
|
||||||
|
SEAM_TRIGGER_SUBTILES,
|
||||||
|
WARP_TRIGGER_SUBTILES,
|
||||||
|
type SceneEntrance,
|
||||||
|
type SceneWarp,
|
||||||
|
type SceneWaypoint,
|
||||||
|
} from '../../game/level-links.ts'
|
||||||
|
import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts'
|
||||||
|
import { SUB_TILES_PER_TILE } from '../../game/map.ts'
|
||||||
|
import { getTblLang } from '../../i18n/lang.ts'
|
||||||
|
import { SEAM_FADE_MS, WARP_FADE_MS, subTileToScene } from '../transition.ts'
|
||||||
|
|
||||||
|
export {
|
||||||
|
SEAM_FADE_MS,
|
||||||
|
SEAM_TRIGGER_SUBTILES,
|
||||||
|
WARP_FADE_MS,
|
||||||
|
WARP_TRIGGER_SUBTILES,
|
||||||
|
subTileToScene,
|
||||||
|
type SceneEntrance,
|
||||||
|
type SceneWarp,
|
||||||
|
type SceneWaypoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum range in scene pixels for Telekinesis (Skill 43) warp activation. */
|
||||||
|
export const TELEKINESIS_RANGE_PX = 320
|
||||||
|
/** Radial proximity click tolerance in scene pixels for a SceneWarp. */
|
||||||
|
export const WARP_CLICK_RADIUS_PX = 56
|
||||||
|
/** Radial proximity click tolerance in scene pixels for a SceneEntrance. */
|
||||||
|
export const ENTRANCE_CLICK_RADIUS_PX = 56
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert scene pixel coordinates back to isometric sub-tile coordinates.
|
||||||
|
*/
|
||||||
|
export function sceneToSubTile(
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const dx = (worldX - grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
|
const dy = (worldY - grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
|
return { x: Math.round((dy + dx) / 2), y: Math.round((dy - dx) / 2) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the scene coordinate for a `SceneWarp` overhead destination badge.
|
||||||
|
*/
|
||||||
|
export function getWarpLabelWorldPos(
|
||||||
|
warp: SceneWarp,
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const anchor = subTileToScene(warp.x, warp.y, grid)
|
||||||
|
if (warp.selectDX > 0 && warp.selectDY > 0) {
|
||||||
|
return {
|
||||||
|
x: anchor.x + warp.selectX + warp.selectDX * 0.5,
|
||||||
|
y: anchor.y + warp.selectY - 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: anchor.x,
|
||||||
|
y: anchor.y - 48,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the scene coordinate for a `SceneEntrance` overhead destination badge.
|
||||||
|
*/
|
||||||
|
export function getEntranceLabelWorldPos(
|
||||||
|
entrance: SceneEntrance,
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const anchor = subTileToScene(entrance.x, entrance.y, grid)
|
||||||
|
return {
|
||||||
|
x: anchor.x,
|
||||||
|
y: anchor.y - 48,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit-test a world-space pointer position against a `SceneWarp` (`LvlWarp.txt`
|
||||||
|
* selection bounding box when non-zero, overhead badge footprint, plus radial
|
||||||
|
* proximity to anchor, walkable arrival point, and overhead label).
|
||||||
|
*/
|
||||||
|
export function hitTestWarp(
|
||||||
|
warp: SceneWarp,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
): {
|
||||||
|
hit: boolean
|
||||||
|
dist: number
|
||||||
|
anchorPos: { x: number; y: number }
|
||||||
|
arrivePos: { x: number; y: number }
|
||||||
|
} {
|
||||||
|
const anchorPos = subTileToScene(warp.x, warp.y, grid)
|
||||||
|
const arrivePos = subTileToScene(warp.arriveX, warp.arriveY, grid)
|
||||||
|
if (!(warp.toLevelId > 0)) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
dist: Infinity,
|
||||||
|
anchorPos,
|
||||||
|
arrivePos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const labelPos = getWarpLabelWorldPos(warp, grid)
|
||||||
|
|
||||||
|
let boxDist = Infinity
|
||||||
|
let inBox = false
|
||||||
|
if (warp.selectDX > 0 && warp.selectDY > 0) {
|
||||||
|
const minX = anchorPos.x + warp.selectX
|
||||||
|
const maxX = minX + warp.selectDX
|
||||||
|
const minY = anchorPos.y + warp.selectY
|
||||||
|
const maxY = minY + warp.selectDY
|
||||||
|
if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) {
|
||||||
|
inBox = true
|
||||||
|
const cx = (minX + maxX) * 0.5
|
||||||
|
const cy = (minY + maxY) * 0.5
|
||||||
|
boxDist = Math.hypot(worldX - cx, worldY - cy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeText = getTblLang() === 'ENG'
|
||||||
|
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
|
||||||
|
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
|
||||||
|
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
|
||||||
|
const inBadge =
|
||||||
|
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
|
||||||
|
worldY >= labelPos.y - 28 &&
|
||||||
|
worldY <= labelPos.y + 8
|
||||||
|
const badgeDist = inBadge
|
||||||
|
? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y)
|
||||||
|
const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y)
|
||||||
|
const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y)
|
||||||
|
const distAnchorBody = Math.hypot(worldX - anchorPos.x, worldY - (anchorPos.y - 48))
|
||||||
|
const radialDist = Math.min(distAnchor, distArrive, distLabel, distAnchorBody)
|
||||||
|
const inRadius = radialDist <= WARP_CLICK_RADIUS_PX
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit: inBox || inBadge || inRadius,
|
||||||
|
dist: Math.min(boxDist, badgeDist, radialDist),
|
||||||
|
anchorPos,
|
||||||
|
arrivePos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit-test a world-space pointer position against a `SceneEntrance` (overhead
|
||||||
|
* badge footprint plus radial proximity to entrance anchor, walkable arrival
|
||||||
|
* point, and overhead label).
|
||||||
|
*/
|
||||||
|
export function hitTestEntrance(
|
||||||
|
entrance: SceneEntrance,
|
||||||
|
worldX: number,
|
||||||
|
worldY: number,
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
): {
|
||||||
|
hit: boolean
|
||||||
|
dist: number
|
||||||
|
anchorPos: { x: number; y: number }
|
||||||
|
arrivePos: { x: number; y: number }
|
||||||
|
} {
|
||||||
|
const anchorPos = subTileToScene(entrance.x, entrance.y, grid)
|
||||||
|
const arrivePos = subTileToScene(entrance.arriveX, entrance.arriveY, grid)
|
||||||
|
if (!(entrance.toLevelId > 0)) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
dist: Infinity,
|
||||||
|
anchorPos,
|
||||||
|
arrivePos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const labelPos = getEntranceLabelWorldPos(entrance, grid)
|
||||||
|
|
||||||
|
const badgeText = getTblLang() === 'ENG'
|
||||||
|
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
|
||||||
|
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
|
||||||
|
const badgeHalfWidth = Math.max(64, Math.ceil((badgeText.length * 14 + 16) * 0.5))
|
||||||
|
const inBadge =
|
||||||
|
Math.abs(worldX - labelPos.x) <= badgeHalfWidth &&
|
||||||
|
worldY >= labelPos.y - 28 &&
|
||||||
|
worldY <= labelPos.y + 8
|
||||||
|
const badgeDist = inBadge
|
||||||
|
? Math.hypot((worldX - labelPos.x) * 0.5, worldY - (labelPos.y - 12)) * 0.1
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const distAnchor = Math.hypot(worldX - anchorPos.x, worldY - anchorPos.y)
|
||||||
|
const distArrive = Math.hypot(worldX - arrivePos.x, worldY - arrivePos.y)
|
||||||
|
const distLabel = Math.hypot(worldX - labelPos.x, worldY - labelPos.y)
|
||||||
|
const distArriveBody = Math.hypot(worldX - arrivePos.x, worldY - (arrivePos.y - 48))
|
||||||
|
const radialDist = Math.min(distAnchor, distArrive, distLabel, distArriveBody)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit: inBadge || radialDist <= ENTRANCE_CLICK_RADIUS_PX,
|
||||||
|
dist: Math.min(badgeDist, radialDist),
|
||||||
|
anchorPos,
|
||||||
|
arrivePos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the player is within trigger range (`WARP_TRIGGER_SUBTILES` or
|
||||||
|
* 80px proximity radius) of a warp or entrance's anchor or walkable arrival point.
|
||||||
|
*/
|
||||||
|
export function isPlayerWithinLinkRange(
|
||||||
|
playerX: number,
|
||||||
|
playerY: number,
|
||||||
|
link: { readonly x: number; readonly y: number; readonly arriveX: number; readonly arriveY: number },
|
||||||
|
grid: { readonly originX: number; readonly originY: number },
|
||||||
|
triggerSubTiles = WARP_TRIGGER_SUBTILES,
|
||||||
|
): boolean {
|
||||||
|
const sub = sceneToSubTile(playerX, playerY, grid)
|
||||||
|
const nearAnchorSub = Math.abs(sub.x - link.x) <= triggerSubTiles && Math.abs(sub.y - link.y) <= triggerSubTiles
|
||||||
|
const nearArriveSub = Math.abs(sub.x - link.arriveX) <= triggerSubTiles && Math.abs(sub.y - link.arriveY) <= triggerSubTiles
|
||||||
|
if (nearAnchorSub || nearArriveSub) return true
|
||||||
|
|
||||||
|
const anchorPos = subTileToScene(link.x, link.y, grid)
|
||||||
|
const arrivePos = subTileToScene(link.arriveX, link.arriveY, grid)
|
||||||
|
const distAnchor = Math.hypot(playerX - anchorPos.x, playerY - anchorPos.y)
|
||||||
|
const distArrive = Math.hypot(playerX - arrivePos.x, playerY - arrivePos.y)
|
||||||
|
return Math.min(distAnchor, distArrive) <= 80
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an obstacle-aware immediate steering target toward `navTarget` on `grid`.
|
||||||
|
* When the direct sub-tile line between the player and `navTarget` is unobstructed,
|
||||||
|
* returns `navTarget` directly. When blocked by dungeon walls in `grid.blocked`,
|
||||||
|
* computes an 8-directional shortest walkable sub-tile path (A*) around corners and
|
||||||
|
* returns the furthest visible waypoint along that path.
|
||||||
|
*/
|
||||||
|
export function resolveObstacleAwareSteerTarget(
|
||||||
|
playerX: number,
|
||||||
|
playerY: number,
|
||||||
|
navTarget: { readonly x: number; readonly y: number },
|
||||||
|
grid: CollisionGrid | undefined,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
if (!grid || !grid.blocked || grid.blocked.length === 0 || grid.gridWidth <= 0 || grid.cellsY <= 0) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridW = grid.gridWidth
|
||||||
|
const gridH = grid.cellsY * SUB_TILES_PER_TILE
|
||||||
|
const startSub = sceneToSubTile(playerX, playerY, grid)
|
||||||
|
const rawEndSub = sceneToSubTile(navTarget.x, navTarget.y, grid)
|
||||||
|
|
||||||
|
if (startSub.x < 0 || startSub.y < 0 || startSub.x >= gridW || startSub.y >= gridH) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
const endSub = {
|
||||||
|
x: Math.max(0, Math.min(gridW - 1, rawEndSub.x)),
|
||||||
|
y: Math.max(0, Math.min(gridH - 1, rawEndSub.y)),
|
||||||
|
}
|
||||||
|
if (startSub.x === endSub.x && startSub.y === endSub.y) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubTileBlocked = (sx: number, sy: number): boolean => {
|
||||||
|
if (sx < 0 || sy < 0 || sx >= gridW || sy >= gridH) return true
|
||||||
|
return grid.blocked[sy * gridW + sx] !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWallNeighbor = (sx: number, sy: number): boolean =>
|
||||||
|
isSubTileBlocked(sx - 1, sy) ||
|
||||||
|
isSubTileBlocked(sx + 1, sy) ||
|
||||||
|
isSubTileBlocked(sx, sy - 1) ||
|
||||||
|
isSubTileBlocked(sx, sy + 1)
|
||||||
|
|
||||||
|
const hasClearLine = (x0: number, y0: number, x1: number, y1: number, requireClearance: boolean): boolean => {
|
||||||
|
let cx = x0
|
||||||
|
let cy = y0
|
||||||
|
const dx = Math.abs(x1 - x0)
|
||||||
|
const dy = Math.abs(y1 - y0)
|
||||||
|
const sx = x0 < x1 ? 1 : -1
|
||||||
|
const sy = y0 < y1 ? 1 : -1
|
||||||
|
let err = dx - dy
|
||||||
|
let step = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (step > 0) {
|
||||||
|
if (isSubTileBlocked(cx, cy)) return false
|
||||||
|
if (requireClearance && (cx !== x1 || cy !== y1) && hasWallNeighbor(cx, cy)) return false
|
||||||
|
}
|
||||||
|
if (cx === x1 && cy === y1) break
|
||||||
|
const prevX = cx
|
||||||
|
const prevY = cy
|
||||||
|
const e2 = 2 * err
|
||||||
|
if (e2 > -dy) {
|
||||||
|
err -= dy
|
||||||
|
cx += sx
|
||||||
|
}
|
||||||
|
if (e2 < dx) {
|
||||||
|
err += dx
|
||||||
|
cy += sy
|
||||||
|
}
|
||||||
|
if (cx !== prevX && cy !== prevY) {
|
||||||
|
if (isSubTileBlocked(cx, prevY) || isSubTileBlocked(prevX, cy)) return false
|
||||||
|
}
|
||||||
|
step += 1
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasClearLine(startSub.x, startSub.y, endSub.x, endSub.y, false)) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded 8-directional A* search on sub-tile grid
|
||||||
|
const maxExpanded = 4096
|
||||||
|
const gScore = new Map<number, number>()
|
||||||
|
const parent = new Map<number, number>()
|
||||||
|
const closed = new Set<number>()
|
||||||
|
const open: Array<{ key: number; x: number; y: number; g: number; f: number }> = []
|
||||||
|
|
||||||
|
const startKey = startSub.y * gridW + startSub.x
|
||||||
|
const endKey = endSub.y * gridW + endSub.x
|
||||||
|
const heuristic = (x: number, y: number): number => Math.hypot(endSub.x - x, endSub.y - y)
|
||||||
|
|
||||||
|
gScore.set(startKey, 0)
|
||||||
|
open.push({ key: startKey, x: startSub.x, y: startSub.y, g: 0, f: heuristic(startSub.x, startSub.y) })
|
||||||
|
|
||||||
|
const dirs: ReadonlyArray<readonly [number, number, number]> = [
|
||||||
|
[1, 0, 1],
|
||||||
|
[-1, 0, 1],
|
||||||
|
[0, 1, 1],
|
||||||
|
[0, -1, 1],
|
||||||
|
[1, 1, Math.SQRT2],
|
||||||
|
[1, -1, Math.SQRT2],
|
||||||
|
[-1, 1, Math.SQRT2],
|
||||||
|
[-1, -1, Math.SQRT2],
|
||||||
|
]
|
||||||
|
|
||||||
|
let reachedKey: number | null = null
|
||||||
|
let bestFallbackKey: number | null = null
|
||||||
|
let bestFallbackDist = heuristic(startSub.x, startSub.y)
|
||||||
|
let bestFallbackG = Infinity
|
||||||
|
let expanded = 0
|
||||||
|
|
||||||
|
while (open.length > 0 && expanded < maxExpanded) {
|
||||||
|
let bestIdx = 0
|
||||||
|
for (let i = 1; i < open.length; i += 1) {
|
||||||
|
if (open[i]!.f < open[bestIdx]!.f) bestIdx = i
|
||||||
|
}
|
||||||
|
const cur = open[bestIdx]!
|
||||||
|
open[bestIdx] = open[open.length - 1]!
|
||||||
|
open.pop()
|
||||||
|
|
||||||
|
if (closed.has(cur.key)) continue
|
||||||
|
closed.add(cur.key)
|
||||||
|
expanded += 1
|
||||||
|
|
||||||
|
if (cur.key === endKey) {
|
||||||
|
reachedKey = cur.key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cur.key !== startKey) {
|
||||||
|
const hCur = heuristic(cur.x, cur.y)
|
||||||
|
if (
|
||||||
|
hCur < bestFallbackDist - 1e-6 ||
|
||||||
|
(Math.abs(hCur - bestFallbackDist) <= 1e-6 && cur.g < bestFallbackG)
|
||||||
|
) {
|
||||||
|
bestFallbackDist = hCur
|
||||||
|
bestFallbackG = cur.g
|
||||||
|
bestFallbackKey = cur.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [dx, dy, baseCost] of dirs) {
|
||||||
|
const nx = cur.x + dx
|
||||||
|
const ny = cur.y + dy
|
||||||
|
if (nx < 0 || ny < 0 || nx >= gridW || ny >= gridH) continue
|
||||||
|
const nKey = ny * gridW + nx
|
||||||
|
if (closed.has(nKey)) continue
|
||||||
|
if (nKey !== endKey && isSubTileBlocked(nx, ny)) continue
|
||||||
|
if (dx !== 0 && dy !== 0) {
|
||||||
|
if (isSubTileBlocked(cur.x + dx, cur.y) || isSubTileBlocked(cur.x, cur.y + dy)) continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallPenalty = (nKey !== endKey && hasWallNeighbor(nx, ny)) ? 0.6 : 0
|
||||||
|
const tentativeG = cur.g + baseCost + wallPenalty
|
||||||
|
const prevG = gScore.get(nKey)
|
||||||
|
if (prevG === undefined || tentativeG < prevG) {
|
||||||
|
gScore.set(nKey, tentativeG)
|
||||||
|
parent.set(nKey, cur.key)
|
||||||
|
open.push({ key: nKey, x: nx, y: ny, g: tentativeG, f: tentativeG + heuristic(nx, ny) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalKey = reachedKey ?? bestFallbackKey
|
||||||
|
if (finalKey === null) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
const path: Array<{ x: number; y: number }> = []
|
||||||
|
let curr: number | undefined = finalKey
|
||||||
|
while (curr !== undefined) {
|
||||||
|
const py = Math.floor(curr / gridW)
|
||||||
|
const px = curr - py * gridW
|
||||||
|
path.push({ x: px, y: py })
|
||||||
|
if (curr === startKey) break
|
||||||
|
curr = parent.get(curr)
|
||||||
|
}
|
||||||
|
path.reverse()
|
||||||
|
|
||||||
|
if (path.length <= 1) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetIdx = 1
|
||||||
|
for (let i = path.length - 1; i >= 2; i -= 1) {
|
||||||
|
const pt = path[i]!
|
||||||
|
if (hasClearLine(startSub.x, startSub.y, pt.x, pt.y, true)) {
|
||||||
|
targetIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIdx === path.length - 1 && reachedKey !== null) {
|
||||||
|
return navTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
const wp = path[targetIdx]!
|
||||||
|
const wpScene = subTileToScene(wp.x, wp.y, grid)
|
||||||
|
if (Math.hypot(wpScene.x - playerX, wpScene.y - playerY) <= 10 && targetIdx + 1 < path.length) {
|
||||||
|
const nextWp = path[targetIdx + 1]!
|
||||||
|
return subTileToScene(nextWp.x, nextWp.y, grid)
|
||||||
|
}
|
||||||
|
return wpScene
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The facing index for a screen-space direction.
|
||||||
|
*
|
||||||
|
* @param x - screen x direction, -1..1.
|
||||||
|
* @param y - screen y direction, -1..1 (down positive).
|
||||||
|
* @returns the facing index, 0 = south, turning clockwise (west).
|
||||||
|
*/
|
||||||
|
export function facingOf(x: number, y: number): number {
|
||||||
|
const angle = Math.atan2(-x, y)
|
||||||
|
return Math.round((angle / (Math.PI / 4)) + 8) % 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cell containing a scene point.
|
||||||
|
*
|
||||||
|
* @param grid - the collision grid.
|
||||||
|
* @param x - scene x.
|
||||||
|
* @param y - scene y.
|
||||||
|
* @returns rounded-down cell coordinates.
|
||||||
|
*/
|
||||||
|
export function cellOf(grid: CollisionGrid, x: number, y: number): { x: number; y: number } {
|
||||||
|
const px = x - grid.originX
|
||||||
|
const py = y - grid.originY
|
||||||
|
return {
|
||||||
|
x: Math.floor((px / ORTHO_CELL_WIDTH + py / ORTHO_CELL_HEIGHT) / 2),
|
||||||
|
y: Math.floor((py / ORTHO_CELL_HEIGHT - px / ORTHO_CELL_WIDTH) / 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenToWorldParams {
|
||||||
|
readonly clientX: number
|
||||||
|
readonly clientY: number
|
||||||
|
readonly rect: { readonly left: number; readonly top: number; readonly width: number; readonly height: number }
|
||||||
|
readonly canvas: { readonly width: number; readonly height: number }
|
||||||
|
readonly camX: number
|
||||||
|
readonly camY: number
|
||||||
|
readonly cameraZoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps screen client coordinates (pixels) to world coordinates using the exact
|
||||||
|
* inverse camera transform.
|
||||||
|
*/
|
||||||
|
export function screenToWorld(params: ScreenToWorldParams): { x: number; y: number } {
|
||||||
|
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
|
||||||
|
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
|
||||||
|
const worldX = params.camX + (params.clientX - params.rect.left - params.rect.width * 0.5) / (params.cameraZoom * scaleX)
|
||||||
|
const worldY = params.camY + (params.clientY - params.rect.top - params.rect.height * 0.5) / (params.cameraZoom * scaleY)
|
||||||
|
return { x: worldX, y: worldY }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,616 @@
|
||||||
|
import type { MonsterPack, MonsterStats, SafeZone } from '../../game/combat.ts'
|
||||||
|
import {
|
||||||
|
ORTHO_CELL_HEIGHT,
|
||||||
|
ORTHO_CELL_WIDTH,
|
||||||
|
cellAt,
|
||||||
|
isBlockedAt,
|
||||||
|
levelSeed,
|
||||||
|
type CollisionGrid,
|
||||||
|
} from '../../game/d2map.ts'
|
||||||
|
import type { DropTables } from '../../game/drop-pipeline.ts'
|
||||||
|
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
|
||||||
|
import type { SceneEntrance, SceneWarp, SceneWaypoint } from '../../game/level-links.ts'
|
||||||
|
import { allocatePacksToRooms, type MapRoom } from '../../game/monster-rooms.ts'
|
||||||
|
import type { StreamingRoomDef } from '../../game/monster-streaming.ts'
|
||||||
|
import {
|
||||||
|
ELITE_HEALTH_MULTIPLIER,
|
||||||
|
MONUMOD_CONSTANTS,
|
||||||
|
applyEliteModifiers,
|
||||||
|
enrichMonsterPacks,
|
||||||
|
rollEliteModifiers,
|
||||||
|
} from '../../game/monsters.ts'
|
||||||
|
import { Rng } from '../../game/rng.ts'
|
||||||
|
import { subTileToScene } from '../transition.ts'
|
||||||
|
|
||||||
|
/** Base paths tried in order when `?base=` is absent. */
|
||||||
|
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side of a streaming room, in cells.
|
||||||
|
*
|
||||||
|
* `D2Common`'s DRLG cuts a wilderness level into rooms of a handful of tiles
|
||||||
|
* each; a generated level here is 8 cells per generator block, so 24 cells is
|
||||||
|
* three blocks — about a screen and a half, which is the granularity at which
|
||||||
|
* populating a room is cheap enough to do inside one frame.
|
||||||
|
*/
|
||||||
|
export const ROOM_CELL_SIZE = 24
|
||||||
|
/**
|
||||||
|
* Distance at which a room is populated and woken, in scene pixels.
|
||||||
|
*
|
||||||
|
* The viewport is ~1280×720, and the isometric projection makes a screen
|
||||||
|
* roughly 1300px wide in scene space, so 1600 is "just past the edge of the
|
||||||
|
* screen": monsters exist before they can be seen, never in front of the
|
||||||
|
* player's eyes.
|
||||||
|
*/
|
||||||
|
export const ROOM_ACTIVATION_RADIUS = 1600
|
||||||
|
/**
|
||||||
|
* Distance at which a room goes back to sleep, in scene pixels.
|
||||||
|
*
|
||||||
|
* Deliberately larger than the activation radius. The gap is hysteresis: a
|
||||||
|
* player pacing across a room boundary would otherwise toggle a room's AI on
|
||||||
|
* and off every few frames.
|
||||||
|
*/
|
||||||
|
export const ROOM_DEACTIVATION_RADIUS = 2400
|
||||||
|
|
||||||
|
/** Town levels across Acts 1..5 that must strictly remain at 0 monster density. */
|
||||||
|
export const TOWN_LEVEL_IDS = new Set<number>([1, 40, 75, 103, 109])
|
||||||
|
|
||||||
|
export function isTownLevel(levelId: number | undefined | null): boolean {
|
||||||
|
if (levelId === undefined || levelId === null || levelId <= 0) return false
|
||||||
|
return TOWN_LEVEL_IDS.has(levelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DensityMultiplier = 0.5 | 1 | 2 | 4 | 8
|
||||||
|
export type EliteMultiplier = 0.5 | 1 | 2 | 4 | 'all'
|
||||||
|
|
||||||
|
export function parseDensityMultiplier(raw: string | number | null | undefined): DensityMultiplier {
|
||||||
|
if (raw === null || raw === undefined) return 1
|
||||||
|
const num = typeof raw === 'number' ? raw : parseFloat(String(raw).trim())
|
||||||
|
if (num === 0.5 || num === 1 || num === 2 || num === 4 || num === 8) {
|
||||||
|
return num as DensityMultiplier
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEliteMultiplier(raw: string | number | null | undefined): EliteMultiplier {
|
||||||
|
if (raw === null || raw === undefined) return 1
|
||||||
|
const str = String(raw).trim().toLowerCase()
|
||||||
|
if (str === 'all') return 'all'
|
||||||
|
const num = parseFloat(str)
|
||||||
|
if (num === 0.5 || num === 1 || num === 2 || num === 4) {
|
||||||
|
return num as EliteMultiplier
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteToChampion(pack: MonsterPack, rng: Rng): MonsterPack {
|
||||||
|
const leaderBase = pack.members[0]
|
||||||
|
if (!leaderBase) return pack
|
||||||
|
const count = rng.int(2, 4)
|
||||||
|
const mods = rollEliteModifiers('champion', rng)
|
||||||
|
const members: MonsterStats[] = []
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const base = pack.members[i % pack.members.length] ?? leaderBase
|
||||||
|
const scaledBase: MonsterStats = {
|
||||||
|
...base,
|
||||||
|
hp: Math.max(1, Math.round(base.hp * ELITE_HEALTH_MULTIPLIER.champion)),
|
||||||
|
}
|
||||||
|
members.push(applyEliteModifiers(scaledBase, 'champion', mods))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
members,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promoteToUnique(pack: MonsterPack, rng: Rng): MonsterPack {
|
||||||
|
const leaderBase = pack.members[0]
|
||||||
|
if (!leaderBase) return pack
|
||||||
|
const minionCount = rng.int(3, 6)
|
||||||
|
const mods = rollEliteModifiers('unique', rng)
|
||||||
|
const scaledLeader: MonsterStats = {
|
||||||
|
...leaderBase,
|
||||||
|
hp: Math.max(1, Math.round(leaderBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||||
|
}
|
||||||
|
const leader = applyEliteModifiers(scaledLeader, 'unique', mods)
|
||||||
|
const members: MonsterStats[] = [leader]
|
||||||
|
for (let i = 0; i < minionCount; i += 1) {
|
||||||
|
const minionBase = pack.members[(i + 1) % pack.members.length] ?? leaderBase
|
||||||
|
const scaledMinion: MonsterStats = {
|
||||||
|
...minionBase,
|
||||||
|
hp: Math.max(1, Math.round(minionBase.hp * (1 + MONUMOD_CONSTANTS.minionHpPct / 100))),
|
||||||
|
}
|
||||||
|
members.push(applyEliteModifiers(scaledMinion, 'minion', []))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
members,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function demoteToNormal(pack: MonsterPack): MonsterPack {
|
||||||
|
const members: MonsterStats[] = pack.members.map(m => {
|
||||||
|
let hp = m.hp
|
||||||
|
let level = m.level
|
||||||
|
if (m.rank === 'champion') {
|
||||||
|
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.champion))
|
||||||
|
if (level !== undefined) {
|
||||||
|
level = Math.max(1, level - 2)
|
||||||
|
}
|
||||||
|
} else if (m.rank === 'unique') {
|
||||||
|
hp = Math.max(1, Math.round(hp / ELITE_HEALTH_MULTIPLIER.unique))
|
||||||
|
if (level !== undefined) {
|
||||||
|
level = Math.max(1, level - 3)
|
||||||
|
}
|
||||||
|
} else if (m.rank === 'minion') {
|
||||||
|
hp = Math.max(1, Math.round(hp / (1 + MONUMOD_CONSTANTS.minionHpPct / 100)))
|
||||||
|
if (level !== undefined) {
|
||||||
|
level = Math.max(1, level - 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
hp,
|
||||||
|
...(level !== undefined ? { level } : {}),
|
||||||
|
rank: 'normal',
|
||||||
|
modifiers: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
members,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RebudgetOptions {
|
||||||
|
readonly densityMultiplier?: number | string | undefined
|
||||||
|
readonly eliteMultiplier?: number | string | undefined
|
||||||
|
readonly levelId?: number | undefined
|
||||||
|
readonly seed?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebudgetMonsterPacks(
|
||||||
|
basePacks: readonly MonsterPack[],
|
||||||
|
options?: RebudgetOptions,
|
||||||
|
): MonsterPack[] {
|
||||||
|
const levelId = options?.levelId
|
||||||
|
if (levelId !== undefined && isTownLevel(levelId)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (!basePacks || basePacks.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques)
|
||||||
|
|
||||||
|
const density = parseDensityMultiplier(options?.densityMultiplier)
|
||||||
|
const elite = parseEliteMultiplier(options?.eliteMultiplier)
|
||||||
|
|
||||||
|
if (density === 1 && elite === 1) {
|
||||||
|
return enrichedBasePacks.map(pack => ({
|
||||||
|
...pack,
|
||||||
|
members: pack.members.map(m => ({ ...m })),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = new Rng(options?.seed ?? 0x9e37)
|
||||||
|
|
||||||
|
// Separate fixed / landmark packs from regular wild packs
|
||||||
|
const fixedPacks = enrichedBasePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined)
|
||||||
|
const regularPacks = enrichedBasePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined)
|
||||||
|
|
||||||
|
// 1. Density scaling on regular packs
|
||||||
|
let scaledRegularPacks: MonsterPack[] = []
|
||||||
|
if (regularPacks.length > 0) {
|
||||||
|
const targetCount = Math.max(1, Math.round(regularPacks.length * density))
|
||||||
|
if (targetCount <= regularPacks.length) {
|
||||||
|
for (let i = 0; i < targetCount; i += 1) {
|
||||||
|
const idx = Math.floor((i * regularPacks.length) / targetCount)
|
||||||
|
const src = regularPacks[idx]!
|
||||||
|
scaledRegularPacks.push({
|
||||||
|
...src,
|
||||||
|
members: src.members.map(m => ({ ...m })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < targetCount; i += 1) {
|
||||||
|
const src = regularPacks[i % regularPacks.length]!
|
||||||
|
scaledRegularPacks.push({
|
||||||
|
...src,
|
||||||
|
members: src.members.map(m => ({ ...m })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Elite scaling on regular packs
|
||||||
|
if (elite === 'all') {
|
||||||
|
scaledRegularPacks = scaledRegularPacks.map((pack, idx) => {
|
||||||
|
const normalPack = demoteToNormal(pack)
|
||||||
|
const packRng = rng.fork(`all-elite-${idx}`)
|
||||||
|
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||||||
|
return isChampion ? promoteToChampion(normalPack, packRng) : promoteToUnique(normalPack, packRng)
|
||||||
|
})
|
||||||
|
} else if (elite !== 1) {
|
||||||
|
const currentEliteIndices: number[] = []
|
||||||
|
const currentNormalIndices: number[] = []
|
||||||
|
for (let i = 0; i < scaledRegularPacks.length; i += 1) {
|
||||||
|
const leaderRank = scaledRegularPacks[i]?.members[0]?.rank
|
||||||
|
if (leaderRank === 'champion' || leaderRank === 'unique') {
|
||||||
|
currentEliteIndices.push(i)
|
||||||
|
} else {
|
||||||
|
currentNormalIndices.push(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEliteIndices.length > 0) {
|
||||||
|
const targetEliteCount = Math.min(scaledRegularPacks.length, Math.max(0, Math.round(currentEliteIndices.length * elite)))
|
||||||
|
if (targetEliteCount < currentEliteIndices.length) {
|
||||||
|
const toDemoteCount = currentEliteIndices.length - targetEliteCount
|
||||||
|
for (let i = 0; i < toDemoteCount; i += 1) {
|
||||||
|
const idx = currentEliteIndices[currentEliteIndices.length - 1 - i]!
|
||||||
|
scaledRegularPacks[idx] = demoteToNormal(scaledRegularPacks[idx]!)
|
||||||
|
}
|
||||||
|
} else if (targetEliteCount > currentEliteIndices.length) {
|
||||||
|
const toPromoteCount = Math.min(currentNormalIndices.length, targetEliteCount - currentEliteIndices.length)
|
||||||
|
for (let i = 0; i < toPromoteCount; i += 1) {
|
||||||
|
const idx = currentNormalIndices[i]!
|
||||||
|
const packRng = rng.fork(`promote-${idx}`)
|
||||||
|
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||||||
|
scaledRegularPacks[idx] = isChampion
|
||||||
|
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
|
||||||
|
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (elite > 1 && currentNormalIndices.length > 0) {
|
||||||
|
const targetEliteCount = Math.min(
|
||||||
|
scaledRegularPacks.length,
|
||||||
|
Math.max(1, Math.round(scaledRegularPacks.length * 0.05 * elite)),
|
||||||
|
)
|
||||||
|
const toPromote = Math.min(currentNormalIndices.length, targetEliteCount)
|
||||||
|
for (let i = 0; i < toPromote; i += 1) {
|
||||||
|
const idx = currentNormalIndices[i]!
|
||||||
|
const packRng = rng.fork(`promote-zero-${idx}`)
|
||||||
|
const isChampion = packRng.next() < (MONUMOD_CONSTANTS.championChance / 100)
|
||||||
|
scaledRegularPacks[idx] = isChampion
|
||||||
|
? promoteToChampion(scaledRegularPacks[idx]!, packRng)
|
||||||
|
: promoteToUnique(scaledRegularPacks[idx]!, packRng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...fixedPacks.map(p => ({ ...p, members: p.members.map(m => ({ ...m })) })),
|
||||||
|
...scaledRegularPacks,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroClassInfo {
|
||||||
|
readonly token: string
|
||||||
|
readonly code: string
|
||||||
|
readonly nameZh: string
|
||||||
|
readonly nameEn: string
|
||||||
|
readonly defaultLeftSkill: number
|
||||||
|
readonly defaultRightSkill: number
|
||||||
|
readonly charIdentity: {
|
||||||
|
readonly name: string
|
||||||
|
readonly classNameZh: string
|
||||||
|
readonly classNameEn: string
|
||||||
|
readonly str: number
|
||||||
|
readonly dex: number
|
||||||
|
readonly vit: number
|
||||||
|
readonly ene: number
|
||||||
|
readonly baseMaxHp: number
|
||||||
|
readonly baseMaxMana: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HERO_CLASSES: Record<string, HeroClassInfo> = {
|
||||||
|
so: {
|
||||||
|
token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress',
|
||||||
|
defaultLeftSkill: 47, defaultRightSkill: 64,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Isendra', classNameZh: '女法师', classNameEn: 'Sorceress',
|
||||||
|
str: 156, dex: 75, vit: 315, ene: 95, baseMaxHp: 850, baseMaxMana: 620,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
am: {
|
||||||
|
token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon',
|
||||||
|
defaultLeftSkill: 6, defaultRightSkill: 7,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Cassia', classNameZh: '亚马逊', classNameEn: 'Amazon',
|
||||||
|
str: 135, dex: 210, vit: 250, ene: 50, baseMaxHp: 920, baseMaxMana: 320,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ne: {
|
||||||
|
token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer',
|
||||||
|
defaultLeftSkill: 67, defaultRightSkill: 70,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Xul', classNameZh: '死灵法师', classNameEn: 'Necromancer',
|
||||||
|
str: 120, dex: 80, vit: 290, ene: 120, baseMaxHp: 780, baseMaxMana: 580,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pa: {
|
||||||
|
token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin',
|
||||||
|
defaultLeftSkill: 96, defaultRightSkill: 112,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Cartographus', classNameZh: '圣骑士', classNameEn: 'Paladin',
|
||||||
|
str: 175, dex: 120, vit: 340, ene: 45, baseMaxHp: 1150, baseMaxMana: 380,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ba: {
|
||||||
|
token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian',
|
||||||
|
defaultLeftSkill: 126, defaultRightSkill: 133,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
|
||||||
|
str: 235, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dz: {
|
||||||
|
token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid',
|
||||||
|
defaultLeftSkill: 225, defaultRightSkill: 229,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Loreov', classNameZh: '德鲁伊', classNameEn: 'Druid',
|
||||||
|
str: 165, dex: 90, vit: 330, ene: 70, baseMaxHp: 1050, baseMaxMana: 420,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin',
|
||||||
|
defaultLeftSkill: 254, defaultRightSkill: 251,
|
||||||
|
charIdentity: {
|
||||||
|
name: 'Natalya', classNameZh: '刺客', classNameEn: 'Assassin',
|
||||||
|
str: 150, dex: 180, vit: 280, ene: 60, baseMaxHp: 960, baseMaxMana: 360,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHeroToken(input: string | null | undefined): string {
|
||||||
|
if (!input) return 'so'
|
||||||
|
const lower = input.toLowerCase().trim()
|
||||||
|
if (HERO_CLASSES[lower]) return lower
|
||||||
|
const codeMap: Record<string, string> = {
|
||||||
|
sor: 'so', sorceress: 'so',
|
||||||
|
ama: 'am', amazon: 'am',
|
||||||
|
nec: 'ne', necromancer: 'ne',
|
||||||
|
pal: 'pa', paladin: 'pa',
|
||||||
|
bar: 'ba', barbarian: 'ba',
|
||||||
|
dru: 'dz', druid: 'dz', dr: 'dz',
|
||||||
|
ass: 'ai', assassin: 'ai', as: 'ai',
|
||||||
|
}
|
||||||
|
return codeMap[lower] ?? 'so'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Town Stash entity (authentic 1.13c Objects.txt row 267 B6). */
|
||||||
|
export interface TownStashEntity {
|
||||||
|
readonly id: number
|
||||||
|
readonly objectsTxtId: number
|
||||||
|
readonly token: string
|
||||||
|
readonly nameZh: string
|
||||||
|
readonly nameEn: string
|
||||||
|
readonly act: number
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
readonly subTileX: number
|
||||||
|
readonly subTileY: number
|
||||||
|
readonly clickRadiusPx: number
|
||||||
|
readonly interactionRangePx: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical town stash coordinates for Acts 1..5.
|
||||||
|
* Grounded in authentic 1.13c DS1 map objects (row 267 B6).
|
||||||
|
*/
|
||||||
|
export const CANONICAL_TOWN_STASHES: Record<number, TownStashEntity> = {
|
||||||
|
1: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 1,
|
||||||
|
x: 4512,
|
||||||
|
y: 1720,
|
||||||
|
subTileX: 112,
|
||||||
|
subTileY: 86,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 2,
|
||||||
|
x: 5328,
|
||||||
|
y: 1568,
|
||||||
|
subTileX: 133,
|
||||||
|
subTileY: 78,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 3,
|
||||||
|
x: 5264,
|
||||||
|
y: 1584,
|
||||||
|
subTileX: 131,
|
||||||
|
subTileY: 79,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 4,
|
||||||
|
x: 704,
|
||||||
|
y: 552,
|
||||||
|
subTileX: 17,
|
||||||
|
subTileY: 27,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
id: 267,
|
||||||
|
objectsTxtId: 267,
|
||||||
|
token: 'B6',
|
||||||
|
nameZh: '储物箱',
|
||||||
|
nameEn: 'Stash',
|
||||||
|
act: 5,
|
||||||
|
x: 3792,
|
||||||
|
y: 1448,
|
||||||
|
subTileX: 94,
|
||||||
|
subTileY: 72,
|
||||||
|
clickRadiusPx: 48,
|
||||||
|
interactionRangePx: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global singleton cache for DropTables using pre-baked embedded canonical drop data (zero network/MPQ). */
|
||||||
|
let sharedDropTablesPromise: Promise<DropTables> | null = null
|
||||||
|
|
||||||
|
export function getSharedDropTables(_bases: readonly string[] = DEFAULT_BASES): Promise<DropTables> {
|
||||||
|
if (!sharedDropTablesPromise) {
|
||||||
|
sharedDropTablesPromise = Promise.resolve(getEmbeddedDropTables())
|
||||||
|
}
|
||||||
|
return sharedDropTablesPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a scene-pixel rectangle on a 7×7 interior grid and ask whether any
|
||||||
|
* point is walkable.
|
||||||
|
*
|
||||||
|
* An axis-aligned rectangle over the isometric diamond includes the four empty
|
||||||
|
* triangles outside the map; without this check those corners become rooms,
|
||||||
|
* `allocatePacksToRooms` deals packs into them, and `spawnMonsterPacks` fails
|
||||||
|
* every placement attempt against void space.
|
||||||
|
*/
|
||||||
|
export function roomHasWalkableGround(
|
||||||
|
grid: CollisionGrid,
|
||||||
|
minX: number,
|
||||||
|
minY: number,
|
||||||
|
maxX: number,
|
||||||
|
maxY: number,
|
||||||
|
): boolean {
|
||||||
|
const samples = 9
|
||||||
|
for (let iy = 1; iy < samples; iy += 1) {
|
||||||
|
const y = minY + (maxY - minY) * (iy / samples)
|
||||||
|
for (let ix = 1; ix < samples; ix += 1) {
|
||||||
|
const x = minX + (maxX - minX) * (ix / samples)
|
||||||
|
if (!isBlockedAt(grid, x, y)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamingRoomLayoutInput {
|
||||||
|
readonly grid: CollisionGrid
|
||||||
|
readonly widthPx: number
|
||||||
|
readonly heightPx: number
|
||||||
|
readonly level: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts a level into streaming rooms and allocates its monster packs to them.
|
||||||
|
*/
|
||||||
|
export function buildStreamingRooms(
|
||||||
|
runtime: StreamingRoomLayoutInput,
|
||||||
|
packs: readonly MonsterPack[],
|
||||||
|
safeZones: readonly SafeZone[],
|
||||||
|
): StreamingRoomDef[] {
|
||||||
|
if (packs.length === 0) return []
|
||||||
|
|
||||||
|
const grid = runtime.grid
|
||||||
|
const roomWidth = (ROOM_CELL_SIZE * ORTHO_CELL_WIDTH) / 2
|
||||||
|
const roomHeight = (ROOM_CELL_SIZE * ORTHO_CELL_HEIGHT) / 2
|
||||||
|
const cellsPerRoomX = Math.max(1, Math.round(roomWidth / ORTHO_CELL_WIDTH))
|
||||||
|
const cellsPerRoomY = Math.max(1, Math.round(roomHeight / ORTHO_CELL_HEIGHT))
|
||||||
|
|
||||||
|
const rooms: MapRoom[] = []
|
||||||
|
let id = 0
|
||||||
|
for (let y = 0; y < runtime.heightPx; y += roomHeight) {
|
||||||
|
for (let x = 0; x < runtime.widthPx; x += roomWidth) {
|
||||||
|
const maxX = Math.min(x + roomWidth, runtime.widthPx)
|
||||||
|
const maxY = Math.min(y + roomHeight, runtime.heightPx)
|
||||||
|
if (maxX - x < 32 || maxY - y < 32) continue
|
||||||
|
if (!roomHasWalkableGround(grid, x, y, maxX, maxY)) continue
|
||||||
|
|
||||||
|
const isSafeZone = safeZones.some(
|
||||||
|
zone =>
|
||||||
|
zone.x + zone.radius > x &&
|
||||||
|
zone.x - zone.radius < maxX &&
|
||||||
|
zone.y + zone.radius > y &&
|
||||||
|
zone.y - zone.radius < maxY,
|
||||||
|
)
|
||||||
|
|
||||||
|
const cell = cellAt(grid, x + (maxX - x) / 2, y + (maxY - y) / 2)
|
||||||
|
rooms.push({
|
||||||
|
id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: maxX - x,
|
||||||
|
height: maxY - y,
|
||||||
|
cellX: Math.floor(cell.x - cellsPerRoomX / 2),
|
||||||
|
cellY: Math.floor(cell.y - cellsPerRoomY / 2),
|
||||||
|
cellsX: cellsPerRoomX,
|
||||||
|
cellsY: cellsPerRoomY,
|
||||||
|
isSafeZone,
|
||||||
|
})
|
||||||
|
id += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rooms.length === 0) return []
|
||||||
|
|
||||||
|
const roomPackMap = allocatePacksToRooms(packs, rooms, levelSeed(runtime.level))
|
||||||
|
const defs: StreamingRoomDef[] = []
|
||||||
|
for (const room of rooms) {
|
||||||
|
const roomPacks = roomPackMap.get(room)
|
||||||
|
if (roomPacks === undefined || roomPacks.length === 0) continue
|
||||||
|
defs.push({
|
||||||
|
id: room.id,
|
||||||
|
bounds: { minX: room.x, minY: room.y, maxX: room.x + room.width, maxY: room.y + room.height },
|
||||||
|
packs: roomPacks,
|
||||||
|
room,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SafeZoneSourceRuntime {
|
||||||
|
readonly grid: CollisionGrid
|
||||||
|
readonly waypoints: readonly SceneWaypoint[]
|
||||||
|
readonly entrances: readonly SceneEntrance[]
|
||||||
|
readonly warps: readonly SceneWarp[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the safe-zone list around the landing point, waypoints, entrances, and warps.
|
||||||
|
*/
|
||||||
|
export function buildLevelSafeZones(
|
||||||
|
runtime: SafeZoneSourceRuntime,
|
||||||
|
landingPoint: { readonly x: number; readonly y: number },
|
||||||
|
): SafeZone[] {
|
||||||
|
const safeZones: SafeZone[] = [{ x: landingPoint.x, y: landingPoint.y, radius: 160 }]
|
||||||
|
for (const wp of runtime.waypoints) {
|
||||||
|
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
|
||||||
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
for (const ent of runtime.entrances) {
|
||||||
|
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
||||||
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
for (const warp of runtime.warps) {
|
||||||
|
const pos = subTileToScene(warp.x, warp.y, runtime.grid)
|
||||||
|
safeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
return safeZones
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,510 @@
|
||||||
|
import { damageMonster, type Monster } from '../../game/combat.ts'
|
||||||
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
|
import { monsterDisplayName } from '../../game/monsters.ts'
|
||||||
|
import {
|
||||||
|
getBatch1SkillDef,
|
||||||
|
getMissileTxtData,
|
||||||
|
getSkillManaCost,
|
||||||
|
isBatch1Skill,
|
||||||
|
type Projectile,
|
||||||
|
} from '../../game/skills.ts'
|
||||||
|
import { getTblLang } from '../../i18n/lang.ts'
|
||||||
|
|
||||||
|
export const PASSIVE_SKILL_IDS = new Set<number>([
|
||||||
|
37, // Warmth
|
||||||
|
61, // Fire Mastery
|
||||||
|
63, // Lightning Mastery
|
||||||
|
65, // Cold Mastery
|
||||||
|
69, // Skeleton Mastery
|
||||||
|
79, // Golem Mastery
|
||||||
|
127, 128, 129, 134, 135, 136, 141, 145, 148, 153, // Barbarian masteries & passives
|
||||||
|
224, // Lycanthropy
|
||||||
|
252, // Claw Mastery
|
||||||
|
])
|
||||||
|
|
||||||
|
export function isPassiveSkill(skillId: number): boolean {
|
||||||
|
if (skillId === 0) return false
|
||||||
|
if (PASSIVE_SKILL_IDS.has(skillId)) return true
|
||||||
|
const def = getBatch1SkillDef(skillId)
|
||||||
|
return def?.type === 'passive'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
|
||||||
|
export type AttackWeaponSubtype =
|
||||||
|
| 'fist'
|
||||||
|
| 'sword'
|
||||||
|
| 'axe'
|
||||||
|
| 'mace'
|
||||||
|
| 'dagger'
|
||||||
|
| 'staff'
|
||||||
|
| 'polearm'
|
||||||
|
| 'spear'
|
||||||
|
| 'bow'
|
||||||
|
| 'crossbow'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export interface AttackWeaponInfo {
|
||||||
|
readonly kind: AttackWeaponKind
|
||||||
|
readonly subtype: AttackWeaponSubtype
|
||||||
|
readonly isRanged: boolean
|
||||||
|
readonly ammoType?: 'arrow' | 'bolt' | undefined
|
||||||
|
readonly damageMin: number
|
||||||
|
readonly damageMax: number
|
||||||
|
readonly reach: number
|
||||||
|
readonly cooldown: number
|
||||||
|
readonly swingTicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOW_CODES = new Set([
|
||||||
|
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
|
||||||
|
'am1', 'am2', 'am6', 'am7', 'amb',
|
||||||
|
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
|
||||||
|
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
|
||||||
|
'amc', 'amd', 'ame', 'amj', 'amk',
|
||||||
|
])
|
||||||
|
|
||||||
|
const XBOW_CODES = new Set([
|
||||||
|
'lxb', 'mxb', 'hxb', 'rxb',
|
||||||
|
'8lx', '8mx', '8hx', '8rx',
|
||||||
|
'6lx', '6mx', '6hx', '6rx',
|
||||||
|
])
|
||||||
|
|
||||||
|
const DAGGER_CODES = new Set([
|
||||||
|
'dgr', 'dir', 'kri', 'bld',
|
||||||
|
'9dg', '9di', '9kr', '9bl',
|
||||||
|
'7dg', '7di', '7kr', '7bl',
|
||||||
|
])
|
||||||
|
|
||||||
|
export interface AttackWeaponInputItem {
|
||||||
|
code?: string | undefined
|
||||||
|
type?: string | undefined
|
||||||
|
wclass?: string | undefined
|
||||||
|
name?: string | undefined
|
||||||
|
nameZh?: string | undefined
|
||||||
|
damage?: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
|
||||||
|
if (!item || !item.code) {
|
||||||
|
return {
|
||||||
|
kind: 'unarmed',
|
||||||
|
subtype: 'fist',
|
||||||
|
isRanged: false,
|
||||||
|
damageMin: 1,
|
||||||
|
damageMax: 2,
|
||||||
|
reach: 52,
|
||||||
|
cooldown: 12,
|
||||||
|
swingTicks: 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = item.code.trim().toLowerCase()
|
||||||
|
const type = (item.type ?? '').trim().toLowerCase()
|
||||||
|
const wclass = (item.wclass ?? '').trim().toLowerCase()
|
||||||
|
const name = (item.name ?? '').toLowerCase()
|
||||||
|
const nameZh = (item.nameZh ?? '').toLowerCase()
|
||||||
|
|
||||||
|
let dMin = 3
|
||||||
|
let dMax = 8
|
||||||
|
if (item.damage) {
|
||||||
|
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
|
||||||
|
if (m) {
|
||||||
|
dMin = parseInt(m[1]!, 10)
|
||||||
|
dMax = parseInt(m[2]!, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
|
||||||
|
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
|
||||||
|
return {
|
||||||
|
kind: 'ranged',
|
||||||
|
subtype: 'crossbow',
|
||||||
|
isRanged: true,
|
||||||
|
ammoType: 'bolt',
|
||||||
|
damageMin: dMin,
|
||||||
|
damageMax: dMax,
|
||||||
|
reach: 450,
|
||||||
|
cooldown: 16,
|
||||||
|
swingTicks: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bow
|
||||||
|
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
|
||||||
|
return {
|
||||||
|
kind: 'ranged',
|
||||||
|
subtype: 'bow',
|
||||||
|
isRanged: true,
|
||||||
|
ammoType: 'arrow',
|
||||||
|
damageMin: dMin,
|
||||||
|
damageMax: dMax,
|
||||||
|
reach: 450,
|
||||||
|
cooldown: 14,
|
||||||
|
swingTicks: 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
|
||||||
|
let subtype: AttackWeaponSubtype | null = null
|
||||||
|
let reach = 52
|
||||||
|
|
||||||
|
if (
|
||||||
|
type === 'knif' ||
|
||||||
|
type === 'tkni' ||
|
||||||
|
DAGGER_CODES.has(code) ||
|
||||||
|
((/dagger|dirk|kris|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) && type !== 'swor')
|
||||||
|
) {
|
||||||
|
subtype = 'dagger'
|
||||||
|
reach = 48
|
||||||
|
} else if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
|
||||||
|
subtype = 'sword'
|
||||||
|
reach = 52
|
||||||
|
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
|
||||||
|
subtype = 'axe'
|
||||||
|
reach = 52
|
||||||
|
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
|
||||||
|
subtype = 'mace'
|
||||||
|
reach = 52
|
||||||
|
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
|
||||||
|
subtype = 'staff'
|
||||||
|
reach = 52
|
||||||
|
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
|
||||||
|
subtype = 'polearm'
|
||||||
|
reach = 60
|
||||||
|
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
|
||||||
|
subtype = 'spear'
|
||||||
|
reach = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtype !== null) {
|
||||||
|
return {
|
||||||
|
kind: 'melee',
|
||||||
|
subtype,
|
||||||
|
isRanged: false,
|
||||||
|
damageMin: dMin,
|
||||||
|
damageMax: dMax,
|
||||||
|
reach,
|
||||||
|
cooldown: 14,
|
||||||
|
swingTicks: 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
|
||||||
|
return {
|
||||||
|
kind: 'unarmed',
|
||||||
|
subtype: 'fist',
|
||||||
|
isRanged: false,
|
||||||
|
damageMin: dMin > 3 ? dMin : 1,
|
||||||
|
damageMax: dMax > 8 ? dMax : 2,
|
||||||
|
reach: 52,
|
||||||
|
cooldown: 12,
|
||||||
|
swingTicks: 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkAmmunition(
|
||||||
|
ammoType: 'arrow' | 'bolt',
|
||||||
|
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
|
||||||
|
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
// 1. Check offhand equipment (weapon2)
|
||||||
|
if (offhandItem && offhandItem.code) {
|
||||||
|
const code = offhandItem.code.toLowerCase().trim()
|
||||||
|
const name = (offhandItem.name ?? '').toLowerCase()
|
||||||
|
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
|
||||||
|
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
|
||||||
|
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check inventory grid / items
|
||||||
|
if (inventory) {
|
||||||
|
const invAny = inventory as any
|
||||||
|
const list: any[] = invAny.gridItems
|
||||||
|
? invAny.gridItems.map((g: any) => g.item ?? g)
|
||||||
|
: invAny.grid
|
||||||
|
? invAny.grid.map((g: any) => g.item ?? g)
|
||||||
|
: (invAny.items ?? [])
|
||||||
|
for (const it of list) {
|
||||||
|
if (!it || !it.code) continue
|
||||||
|
const code = String(it.code).toLowerCase().trim()
|
||||||
|
const name = String(it.name ?? '').toLowerCase()
|
||||||
|
const nameZh = String(it.nameZh ?? '').toLowerCase()
|
||||||
|
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
|
||||||
|
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HudCombatStateLike {
|
||||||
|
mana: number
|
||||||
|
maxMana?: number
|
||||||
|
lang?: string
|
||||||
|
leftPanel?: string
|
||||||
|
toggleLeftPanel?: (panel: any) => void
|
||||||
|
syncPublishedState?: () => void
|
||||||
|
getSkillLevel?: (skillId: number) => number
|
||||||
|
hotkeys?: {
|
||||||
|
readonly availableSkills?: ReadonlyArray<{ readonly skillId: number; readonly manaCost: number }>
|
||||||
|
}
|
||||||
|
inventory?: {
|
||||||
|
playerLevel?: number
|
||||||
|
gold: number
|
||||||
|
readonly equipped?: {
|
||||||
|
readonly weapon1?: AttackWeaponInputItem | null
|
||||||
|
readonly weapon2?: any
|
||||||
|
}
|
||||||
|
autoPlaceInGrid: (item: any) => boolean
|
||||||
|
readonly items?: readonly any[]
|
||||||
|
readonly grid?: readonly any[]
|
||||||
|
readonly gridItems?: readonly any[]
|
||||||
|
}
|
||||||
|
belt?: {
|
||||||
|
autoPlacePotion?: (item: any) => boolean
|
||||||
|
}
|
||||||
|
worldPanels?: {
|
||||||
|
selectedActTab: number
|
||||||
|
setCurrentWaypoint: (waypointId: number | null, levelId: number | null) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkillRange(
|
||||||
|
skillId: number,
|
||||||
|
hudLike?: HudCombatStateLike | null | undefined,
|
||||||
|
equippedWeapon?: AttackWeaponInputItem | null | undefined,
|
||||||
|
): number {
|
||||||
|
if (skillId === 0) {
|
||||||
|
const hud = hudLike ?? ((globalThis as Record<string, any>).__d2webHudInstance as HudCombatStateLike | null | undefined)
|
||||||
|
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
|
||||||
|
const info = getAttackWeaponInfo(weapon)
|
||||||
|
return info.reach
|
||||||
|
}
|
||||||
|
const missileSkillMap: Record<number, string> = {
|
||||||
|
36: 'firebolt',
|
||||||
|
39: 'icebolt',
|
||||||
|
45: 'iceblast',
|
||||||
|
55: 'glacialspike',
|
||||||
|
38: 'chargedbolt',
|
||||||
|
67: 'teeth',
|
||||||
|
84: 'bonespear',
|
||||||
|
93: 'bonespirit',
|
||||||
|
92: 'poisonnova',
|
||||||
|
101: 'holybolt',
|
||||||
|
112: 'blessedhammer',
|
||||||
|
6: 'magicarrow',
|
||||||
|
7: 'firearrow',
|
||||||
|
11: 'icearrow',
|
||||||
|
21: 'icearrow',
|
||||||
|
12: 'arrow',
|
||||||
|
16: 'firearrow',
|
||||||
|
20: 'lightningjavelin',
|
||||||
|
}
|
||||||
|
if (skillId in missileSkillMap) {
|
||||||
|
const missile = getMissileTxtData(missileSkillMap[skillId]!)
|
||||||
|
return missile.distancePx || 1000
|
||||||
|
}
|
||||||
|
const def = getBatch1SkillDef(skillId)
|
||||||
|
if (def !== undefined) {
|
||||||
|
if (def.type === 'melee') return 52
|
||||||
|
if (def.range !== undefined) return def.range
|
||||||
|
}
|
||||||
|
switch (skillId) {
|
||||||
|
case 40: return 0
|
||||||
|
case 42: return 160
|
||||||
|
case 44: return 240
|
||||||
|
case 54: return 450
|
||||||
|
case 56: return 450
|
||||||
|
case 57: return 340
|
||||||
|
case 58: return 0
|
||||||
|
case 59: return 450
|
||||||
|
case 62: return 450
|
||||||
|
case 63: return 0
|
||||||
|
case 64: return 480
|
||||||
|
case 219:
|
||||||
|
case 220: return 0
|
||||||
|
default: return 450
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSkillMana(skillId: number, hudLike?: HudCombatStateLike | null): number {
|
||||||
|
if (skillId === 0) return 0
|
||||||
|
if (hudLike !== null && hudLike !== undefined) {
|
||||||
|
const entry = hudLike.hotkeys?.availableSkills?.find(s => s.skillId === skillId)
|
||||||
|
if (entry !== undefined) return entry.manaCost
|
||||||
|
}
|
||||||
|
if (isBatch1Skill(skillId)) {
|
||||||
|
return getSkillManaCost(skillId, 1)
|
||||||
|
}
|
||||||
|
switch (skillId) {
|
||||||
|
case 41: return 6
|
||||||
|
case 45: return 6
|
||||||
|
case 46: return 11
|
||||||
|
case 51: return 22
|
||||||
|
case 55: return 10
|
||||||
|
case 73: return 3
|
||||||
|
case 78: return 17
|
||||||
|
case 83: return 8
|
||||||
|
case 84: return 7
|
||||||
|
case 88: return 27
|
||||||
|
case 93: return 12
|
||||||
|
case 92: return 20
|
||||||
|
case 12: return 4
|
||||||
|
case 16: return 5
|
||||||
|
case 20: return 8
|
||||||
|
case 21: return 4
|
||||||
|
case 40: return 7
|
||||||
|
case 42: return 9
|
||||||
|
case 59: return 23
|
||||||
|
case 60: return 12
|
||||||
|
case 62: return 20
|
||||||
|
case 64: return 30
|
||||||
|
case 56: return 17
|
||||||
|
case 49: return 11
|
||||||
|
case 38: return 3
|
||||||
|
case 43: return 7
|
||||||
|
case 57: return 19
|
||||||
|
case 58: return 5
|
||||||
|
case 63: return 0
|
||||||
|
case 219:
|
||||||
|
case 220: return 0
|
||||||
|
default: return 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCastOutcome {
|
||||||
|
readonly casted: boolean
|
||||||
|
readonly animMode: 'a1' | 'sc' | null
|
||||||
|
readonly statusMessage: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure simulation helper for normal weapon/unarmed attack (`skillId === 0`).
|
||||||
|
*/
|
||||||
|
export function executeNormalAttack(
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
context: {
|
||||||
|
readonly engine: GameEngine
|
||||||
|
readonly hudLike?: HudCombatStateLike | null | undefined
|
||||||
|
readonly equippedWeapon?: AttackWeaponInputItem | null | undefined
|
||||||
|
readonly equippedAmmo?: any | null | undefined
|
||||||
|
readonly hasAmmo?: boolean | undefined
|
||||||
|
},
|
||||||
|
): SkillCastOutcome {
|
||||||
|
const { engine, hudLike } = context
|
||||||
|
const player = engine.world.player
|
||||||
|
const isEn = (hudLike?.lang ?? getTblLang()) === 'ENG'
|
||||||
|
const globalHud = (globalThis as Record<string, any>).__d2webHudInstance as HudCombatStateLike | null | undefined
|
||||||
|
|
||||||
|
const weapon =
|
||||||
|
context.equippedWeapon ??
|
||||||
|
hudLike?.inventory?.equipped?.weapon1 ??
|
||||||
|
globalHud?.inventory?.equipped?.weapon1 ??
|
||||||
|
null
|
||||||
|
const weaponInfo = getAttackWeaponInfo(weapon)
|
||||||
|
|
||||||
|
if (weaponInfo.isRanged) {
|
||||||
|
const ammo =
|
||||||
|
context.equippedAmmo ??
|
||||||
|
hudLike?.inventory?.equipped?.weapon2 ??
|
||||||
|
globalHud?.inventory?.equipped?.weapon2 ??
|
||||||
|
null
|
||||||
|
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudLike?.inventory)
|
||||||
|
if (!hasAmmo) {
|
||||||
|
return {
|
||||||
|
casted: false,
|
||||||
|
animMode: 'a1',
|
||||||
|
statusMessage: isEn
|
||||||
|
? (weaponInfo.ammoType === 'arrow' ? 'Out of arrows!' : 'Out of bolts!')
|
||||||
|
: (weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player.cooldown = weaponInfo.cooldown
|
||||||
|
player.swingTicks = weaponInfo.swingTicks
|
||||||
|
|
||||||
|
const dx = targetX - player.x
|
||||||
|
const dy = targetY - player.y
|
||||||
|
const len = Math.hypot(dx, dy)
|
||||||
|
const speed = 500
|
||||||
|
const range = weaponInfo.reach
|
||||||
|
const perTick = speed / 25
|
||||||
|
const ttl = Math.max(1, Math.round(range / perTick))
|
||||||
|
|
||||||
|
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
|
||||||
|
|
||||||
|
const projectile: Projectile = {
|
||||||
|
skillId: '0',
|
||||||
|
x: player.x,
|
||||||
|
y: player.y,
|
||||||
|
vx: len > 0 ? (dx / len) * perTick : perTick,
|
||||||
|
vy: len > 0 ? (dy / len) * perTick : 0,
|
||||||
|
damage,
|
||||||
|
ttl,
|
||||||
|
fromPlayer: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
engine.projectiles.push(projectile)
|
||||||
|
engine.metrics.casts += 1
|
||||||
|
return {
|
||||||
|
casted: true,
|
||||||
|
animMode: 'a1',
|
||||||
|
statusMessage: isEn
|
||||||
|
? (weaponInfo.subtype === 'bow' ? 'Attack: Shoot Arrow' : 'Attack: Shoot Bolt')
|
||||||
|
: (weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unarmed or Melee Attack
|
||||||
|
player.cooldown = weaponInfo.cooldown
|
||||||
|
player.swingTicks = weaponInfo.swingTicks
|
||||||
|
|
||||||
|
let targetMonster: Monster | null = null
|
||||||
|
let bestTargetDist = 36
|
||||||
|
for (const monster of engine.world.monsters) {
|
||||||
|
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||||
|
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||||
|
if (distToPlayer <= weaponInfo.reach) {
|
||||||
|
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
|
||||||
|
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
|
||||||
|
const minD = Math.min(distFeet, distBody)
|
||||||
|
if (minD <= bestTargetDist) {
|
||||||
|
bestTargetDist = minD
|
||||||
|
targetMonster = monster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetMonster === null) {
|
||||||
|
let bestDist = weaponInfo.reach
|
||||||
|
for (const monster of engine.world.monsters) {
|
||||||
|
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||||
|
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||||
|
if (dist <= bestDist) {
|
||||||
|
bestDist = dist
|
||||||
|
targetMonster = monster
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
|
||||||
|
let statusMessage: string | null = null
|
||||||
|
if (targetMonster !== null) {
|
||||||
|
damageMonster(engine.world, targetMonster.index, damage)
|
||||||
|
engine.metrics.playerHits += 1
|
||||||
|
if (isEn) {
|
||||||
|
const attackLabel = weaponInfo.kind === 'unarmed' ? 'Unarmed Attack' : 'Attack'
|
||||||
|
statusMessage = `${attackLabel} hit: ${monsterDisplayName(targetMonster.stats, 'ENG')}`
|
||||||
|
} else {
|
||||||
|
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
|
||||||
|
statusMessage = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
casted: true,
|
||||||
|
animMode: 'a1',
|
||||||
|
statusMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import type { WorldArena as WorldArenaType } from '../../game/engine/world-arena.ts'
|
||||||
|
|
||||||
|
export async function initBrowserArenaMode(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('arena') !== '1') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const classParam = params.get('class') || params.get('hero') || 'sor'
|
||||||
|
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
|
||||||
|
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
|
||||||
|
const autocast = params.get('autocast') !== '0'
|
||||||
|
|
||||||
|
const engineBase = ['..', '..', 'game', 'engine'].join('/')
|
||||||
|
const { getSharedDataRegistry } = (await import(
|
||||||
|
/* @vite-ignore */ `${engineBase}/data-registry.ts`
|
||||||
|
)) as typeof import('../../game/engine/data-registry.ts')
|
||||||
|
const { WorldArena } = (await import(
|
||||||
|
/* @vite-ignore */ `${engineBase}/world-arena.ts`
|
||||||
|
)) as typeof import('../../game/engine/world-arena.ts')
|
||||||
|
|
||||||
|
const registry = await getSharedDataRegistry('samples/d2')
|
||||||
|
let arena: WorldArenaType = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: classParam,
|
||||||
|
skillId,
|
||||||
|
slvl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (autocast) {
|
||||||
|
arena.triggerCast()
|
||||||
|
arena.stepTicks(15)
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncWindowArena = () => {
|
||||||
|
const snap = arena.getDebugState() as ReturnType<typeof arena.getDebugState> & {
|
||||||
|
switchAndCastSkill?: (nextClassCode: string, nextSkillId: number, nextSlvl?: number) => unknown
|
||||||
|
}
|
||||||
|
const origTrigger = snap.triggerCast
|
||||||
|
const origStep = snap.stepTicks
|
||||||
|
snap.triggerCast = (tx?: number, ty?: number) => {
|
||||||
|
const out = origTrigger(tx, ty)
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
snap.stepTicks = (cnt: number) => {
|
||||||
|
origStep(cnt)
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
}
|
||||||
|
snap.switchAndCastSkill = (nextClassCode: string, nextSkillId: number, nextSlvl = 20) => {
|
||||||
|
arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: nextClassCode,
|
||||||
|
skillId: nextSkillId,
|
||||||
|
slvl: nextSlvl,
|
||||||
|
})
|
||||||
|
arena.triggerCast()
|
||||||
|
arena.stepTicks(15)
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
return arena.getDebugState()
|
||||||
|
}
|
||||||
|
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderArenaVisualOverlay = (wArena: WorldArenaType) => {
|
||||||
|
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
|
||||||
|
if (!overlayCanvas) {
|
||||||
|
overlayCanvas = document.createElement('canvas')
|
||||||
|
overlayCanvas.id = 'arena-skill-overlay'
|
||||||
|
overlayCanvas.width = window.innerWidth || 960
|
||||||
|
overlayCanvas.height = window.innerHeight || 600
|
||||||
|
overlayCanvas.style.cssText =
|
||||||
|
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
|
||||||
|
document.body.appendChild(overlayCanvas)
|
||||||
|
}
|
||||||
|
const ctx = overlayCanvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const w = overlayCanvas.width
|
||||||
|
const h = overlayCanvas.height
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const dbg = wArena.getDebugState()
|
||||||
|
const cx = w * 0.5
|
||||||
|
const cy = h * 0.52
|
||||||
|
|
||||||
|
// 1. Draw Active Aura / Cast Ring & Class Hero Avatar around Hero
|
||||||
|
const classHue = ((dbg.skillId * 37) % 360)
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.9)' : `hsla(${classHue}, 85%, 65%, 0.85)`
|
||||||
|
ctx.lineWidth = 3.5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(cx, cy + 16, 62 + (dbg.skillId % 18), 32 + (dbg.skillId % 9), 0, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Hero class figure + active weapon/spell glow
|
||||||
|
ctx.fillStyle = `hsla(${classHue}, 75%, 55%, 0.85)`
|
||||||
|
ctx.strokeStyle = '#fff3d1'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(cx, cy - 6, 18, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = '#ffe9a8'
|
||||||
|
ctx.font = 'bold 11px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(`${dbg.classCode.toUpperCase()}:${dbg.charToken.toUpperCase()} [${dbg.playerAnimMode}:${dbg.playerAnimFrame}]`, cx, cy + 35)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
|
||||||
|
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
|
||||||
|
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
|
||||||
|
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
|
||||||
|
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
|
||||||
|
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
|
||||||
|
}
|
||||||
|
for (const tgt of dbg.targetLog) {
|
||||||
|
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
|
||||||
|
const tx = cx + off.dx
|
||||||
|
const ty = cy + off.dy
|
||||||
|
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
|
||||||
|
|
||||||
|
// Dummy shadow + body marker
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = off.color
|
||||||
|
ctx.strokeStyle = '#f5e6c8'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// HP bar
|
||||||
|
ctx.fillStyle = '#1f1914'
|
||||||
|
ctx.fillRect(tx - 44, ty - 38, 88, 9)
|
||||||
|
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
|
||||||
|
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
|
||||||
|
|
||||||
|
ctx.font = 'bold 11px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillStyle = '#f2e6d0'
|
||||||
|
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
|
||||||
|
|
||||||
|
if (tgt.lastDamage > 0) {
|
||||||
|
ctx.fillStyle = '#ffdf59'
|
||||||
|
ctx.font = 'bold 14px monospace'
|
||||||
|
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
|
||||||
|
}
|
||||||
|
if (tgt.states.length > 0) {
|
||||||
|
ctx.fillStyle = '#7be3ff'
|
||||||
|
ctx.font = '10px monospace'
|
||||||
|
ctx.fillText(tgt.states.join(','), tx, ty + 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
|
||||||
|
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
|
||||||
|
ctx.save()
|
||||||
|
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
|
||||||
|
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
|
||||||
|
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
|
||||||
|
ctx.strokeStyle = grad
|
||||||
|
ctx.lineWidth = 5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(cx, cy - 8)
|
||||||
|
ctx.lineTo(cx, cy - 108)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Active Summoned Pets / Traps
|
||||||
|
dbg.petsActive.forEach((pet, idx) => {
|
||||||
|
const px = cx - 95 + idx * 48
|
||||||
|
const py = cy + 44
|
||||||
|
ctx.fillStyle = '#5ce1e6'
|
||||||
|
ctx.strokeStyle = '#112a36'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(px, py, 12, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = '#d7f9ff'
|
||||||
|
ctx.font = 'bold 10px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Top-left 1.13c Skill Verification Telemetry Panel
|
||||||
|
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
|
||||||
|
ctx.strokeStyle = '#c8a15a'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.fillRect(14, 62, 470, 122)
|
||||||
|
ctx.strokeRect(14, 62, 470, 122)
|
||||||
|
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillStyle = '#f0c76e'
|
||||||
|
ctx.font = 'bold 13px monospace'
|
||||||
|
ctx.fillText(
|
||||||
|
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
|
||||||
|
24,
|
||||||
|
82,
|
||||||
|
)
|
||||||
|
ctx.fillStyle = '#e8dcc8'
|
||||||
|
ctx.font = '12px monospace'
|
||||||
|
ctx.fillText(
|
||||||
|
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
|
||||||
|
24,
|
||||||
|
102,
|
||||||
|
)
|
||||||
|
ctx.fillText(
|
||||||
|
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
|
||||||
|
24,
|
||||||
|
122,
|
||||||
|
)
|
||||||
|
ctx.fillText(
|
||||||
|
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
|
||||||
|
24,
|
||||||
|
142,
|
||||||
|
)
|
||||||
|
ctx.fillStyle = '#8ee38e'
|
||||||
|
ctx.fillText(
|
||||||
|
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
|
||||||
|
24,
|
||||||
|
162,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
} catch (err) {
|
||||||
|
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
|
||||||
|
(err as Error)?.stack || err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||||
|
;(window as unknown as { __initBrowserArenaMode?: () => Promise<void> }).__initBrowserArenaMode =
|
||||||
|
initBrowserArenaMode
|
||||||
|
void initBrowserArenaMode()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
import type { Palette } from '../../formats/pal.ts'
|
||||||
|
import type { AnimClipMeta } from '../../game/actor-animator.ts'
|
||||||
|
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../../game/monster-art.ts'
|
||||||
|
import { resolveMonsterArtSpec } from '../../game/monster-mapping.ts'
|
||||||
|
import type { AtlasFrame } from '../../render/atlas.ts'
|
||||||
|
import type { AtlasHandle, SpriteRenderer } from '../../render/renderer.ts'
|
||||||
|
import { normalizeHeroToken } from '../backend/scene-population.ts'
|
||||||
|
|
||||||
|
export { ADDITIVE_MONSTER_TOKENS }
|
||||||
|
|
||||||
|
/** Base locations tried in order when `?base=` is absent. */
|
||||||
|
export const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||||
|
|
||||||
|
/** A composited character, uploaded as its own atlas page. */
|
||||||
|
export interface LoadedCharacter {
|
||||||
|
readonly handle: AtlasHandle
|
||||||
|
/** `atlas.groups[direction][frame]`, ready to draw. */
|
||||||
|
readonly groups: readonly (readonly AtlasFrame[])[]
|
||||||
|
readonly directions: number
|
||||||
|
readonly walk: number
|
||||||
|
readonly stand: number
|
||||||
|
readonly members: number
|
||||||
|
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackedMonsterJson {
|
||||||
|
readonly schema?: number
|
||||||
|
readonly token: string
|
||||||
|
readonly weapon: string
|
||||||
|
readonly file: string
|
||||||
|
readonly width: number
|
||||||
|
readonly height: number
|
||||||
|
readonly blendMode?: 'normal' | 'additive' | undefined
|
||||||
|
readonly directions: number
|
||||||
|
readonly walkOffset: number
|
||||||
|
readonly standOffset: number
|
||||||
|
readonly walkFrames: number
|
||||||
|
readonly standFrames: number
|
||||||
|
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
|
||||||
|
readonly glowGroups?: readonly (readonly [number, number, number, number, number?, number?])[][] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackedCharJson {
|
||||||
|
readonly schema?: number
|
||||||
|
readonly file: string
|
||||||
|
readonly width: number
|
||||||
|
readonly height: number
|
||||||
|
readonly directions: number
|
||||||
|
readonly walk: number
|
||||||
|
readonly stand: number
|
||||||
|
readonly members: number
|
||||||
|
readonly clips?: Readonly<Record<string, AnimClipMeta>>
|
||||||
|
readonly groups: readonly (readonly [number, number, number, number, number?, number?])[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a monster-art load produced, including what it failed to produce.
|
||||||
|
*/
|
||||||
|
export interface MonsterArtLoad {
|
||||||
|
/** Monster id (lower case) to its atlas. */
|
||||||
|
readonly map: Map<string, LoadedMonsterArt>
|
||||||
|
/** Monster ids left with no atlas: every one of these draws as a red box. */
|
||||||
|
readonly missing: string[]
|
||||||
|
/** Loads that threw — a per-spec failure, or the whole function. */
|
||||||
|
readonly errors: number
|
||||||
|
/** COF layers dropped inside the art that *did* load: monsters missing limbs. */
|
||||||
|
readonly layerFailures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The monster ids that got no atlas.
|
||||||
|
*
|
||||||
|
* @param monsterIds - the ids the level asked for.
|
||||||
|
* @param loaded - the ids that loaded, lower case.
|
||||||
|
* @returns the missing ids, lower case and deduplicated, in request order.
|
||||||
|
*/
|
||||||
|
export function missingMonsterArtIds(
|
||||||
|
monsterIds: readonly string[],
|
||||||
|
loaded: ReadonlySet<string>,
|
||||||
|
): string[] {
|
||||||
|
const missing: string[] = []
|
||||||
|
for (const id of monsterIds) {
|
||||||
|
const key = id.toLowerCase()
|
||||||
|
if (!loaded.has(key) && !missing.includes(key)) missing.push(key)
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load monster atlases on-demand for all monster types present in the scene.
|
||||||
|
* Prefers pre-baked indexed PNG + JSON from the act pack when available (< 200ms),
|
||||||
|
* falling back to runtime MPQ HTTP Range extraction only if pre-packed assets are missing.
|
||||||
|
*/
|
||||||
|
export async function loadMonsterArtMap(
|
||||||
|
_bases: readonly string[],
|
||||||
|
monsterIds: readonly string[],
|
||||||
|
_palette: Palette,
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
act?: number,
|
||||||
|
): Promise<MonsterArtLoad> {
|
||||||
|
const map = new Map<string, LoadedMonsterArt>()
|
||||||
|
let errors = 0
|
||||||
|
let layerFailures = 0
|
||||||
|
if (monsterIds.length === 0) return { map, missing: [], errors, layerFailures }
|
||||||
|
try {
|
||||||
|
const tokenSpecs = new Map<string, { token: string; weapon: string }>()
|
||||||
|
for (const id of monsterIds) {
|
||||||
|
const spec = resolveMonsterArtSpec(id)
|
||||||
|
if (spec !== null) {
|
||||||
|
const key = `${spec.token}-${spec.weapon}`
|
||||||
|
if (!tokenSpecs.has(key)) {
|
||||||
|
tokenSpecs.set(key, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedBySpecKey = new Map<string, LoadedMonsterArt>()
|
||||||
|
const fallbackSpecs: Array<[string, { token: string; weapon: string }]> = []
|
||||||
|
|
||||||
|
if (packBase !== undefined) {
|
||||||
|
const globalEntitiesUrl = `${packBase}/entities`
|
||||||
|
const actEntitiesUrl = act !== undefined ? `${packBase}/act${String(act)}/entities` : undefined
|
||||||
|
await Promise.all(
|
||||||
|
[...tokenSpecs.entries()].map(async ([key, spec]) => {
|
||||||
|
const slug = `monster-${spec.token.toLowerCase()}-${spec.weapon.toLowerCase()}`
|
||||||
|
try {
|
||||||
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
|
fetch(`${globalEntitiesUrl}/${slug}.json`),
|
||||||
|
fetch(`${globalEntitiesUrl}/${slug}.r8`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
|
||||||
|
? meta.glowGroups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
loadedBySpecKey.set(key, {
|
||||||
|
token: spec.token,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
|
directions: meta.directions,
|
||||||
|
walkOffset: meta.walkOffset,
|
||||||
|
standOffset: meta.standOffset,
|
||||||
|
walkFrames: meta.walkFrames,
|
||||||
|
standFrames: meta.standFrames,
|
||||||
|
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
||||||
|
layerFailures: [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`monster art ${slug}: packed r8 unavailable: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actEntitiesUrl !== undefined) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
fetch(`${actEntitiesUrl}/${slug}.json`),
|
||||||
|
fetch(`${actEntitiesUrl}/${slug}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedMonsterJson
|
||||||
|
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const glowGroups: AtlasFrame[][] | undefined = meta.glowGroups !== undefined
|
||||||
|
? meta.glowGroups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
loadedBySpecKey.set(key, {
|
||||||
|
token: spec.token,
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
...(glowGroups !== undefined ? { glowGroups } : {}),
|
||||||
|
...(meta.blendMode !== undefined ? { blendMode: meta.blendMode } : {}),
|
||||||
|
directions: meta.directions,
|
||||||
|
walkOffset: meta.walkOffset,
|
||||||
|
standOffset: meta.standOffset,
|
||||||
|
walkFrames: meta.walkFrames,
|
||||||
|
standFrames: meta.standFrames,
|
||||||
|
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
||||||
|
layerFailures: [],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`monster art ${slug}: packed png unavailable: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fallbackSpecs.push([key, spec])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fallbackSpecs.push(...tokenSpecs.entries())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackSpecs.length > 0) {
|
||||||
|
for (const [, spec] of fallbackSpecs) {
|
||||||
|
errors += 1
|
||||||
|
console.error(`monster art ${spec.token} (${spec.weapon}) unavailable in pre-baked packs`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const loaded of loadedBySpecKey.values()) layerFailures += loaded.layerFailures.length
|
||||||
|
|
||||||
|
for (const id of monsterIds) {
|
||||||
|
const spec = resolveMonsterArtSpec(id)
|
||||||
|
if (spec !== null) {
|
||||||
|
const key = `${spec.token}-${spec.weapon}`
|
||||||
|
const loaded = loadedBySpecKey.get(key)
|
||||||
|
if (loaded !== undefined) {
|
||||||
|
map.set(id.toLowerCase(), loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors += 1
|
||||||
|
console.error(`monster art loading error: ${(err as Error).message}`)
|
||||||
|
}
|
||||||
|
return { map, missing: missingMonsterArtIds(monsterIds, new Set(map.keys())), errors, layerFailures }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCharacterArt(
|
||||||
|
_bases: readonly string[],
|
||||||
|
_palette: Palette | null,
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
act?: number,
|
||||||
|
token = 'so',
|
||||||
|
): Promise<LoadedCharacter | null> {
|
||||||
|
const heroToken = normalizeHeroToken(token)
|
||||||
|
if (packBase !== undefined) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, r8Resp] = await Promise.all([
|
||||||
|
fetch(`${packBase}/entities/char-${heroToken}.json`),
|
||||||
|
fetch(`${packBase}/entities/char-${heroToken}.r8`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && r8Resp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedCharJson
|
||||||
|
const indices = new Uint8Array(await r8Resp.arrayBuffer())
|
||||||
|
const handle = renderer.addIndexedAtlas(indices, meta.width, meta.height)
|
||||||
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
directions: meta.directions,
|
||||||
|
walk: meta.walk,
|
||||||
|
stand: meta.stand,
|
||||||
|
members: meta.members,
|
||||||
|
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to per-act PNG fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (act !== undefined) {
|
||||||
|
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
fetch(`${entitiesUrl}/char-${heroToken}.json`),
|
||||||
|
fetch(`${entitiesUrl}/char-${heroToken}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as PackedCharJson
|
||||||
|
const bitmap = await createImageBitmap(await pngResp.blob())
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const groups: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x, y, width, height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
handle,
|
||||||
|
groups,
|
||||||
|
directions: meta.directions,
|
||||||
|
walk: meta.walk,
|
||||||
|
stand: meta.stand,
|
||||||
|
members: meta.members,
|
||||||
|
...(meta.clips !== undefined ? { clips: meta.clips } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// failed to load pack character
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heroToken !== 'so') {
|
||||||
|
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
|
import {
|
||||||
|
calculateBounceHeight,
|
||||||
|
type GroundItemEntity,
|
||||||
|
} from '../../game/ground-items.ts'
|
||||||
|
import type { AtlasFrame } from '../../render/atlas.ts'
|
||||||
|
import type {
|
||||||
|
AtlasHandle,
|
||||||
|
SpriteRenderer,
|
||||||
|
} from '../../render/renderer.ts'
|
||||||
|
import { BAKED_UI_MANIFEST } from '../../ui/baked-ui-meta.ts'
|
||||||
|
import { isGroundItemEtherealOrSocketed } from '../../ui/ground-labels.ts'
|
||||||
|
import type { HudManager } from '../../ui/hud-manager.ts'
|
||||||
|
import { resolveGroundItemSpriteRect } from '../../ui/inventory.ts'
|
||||||
|
|
||||||
|
export type SpriteRect = NonNullable<ReturnType<typeof resolveGroundItemSpriteRect>>
|
||||||
|
|
||||||
|
/** Quality colors for ground item highlights and glints. */
|
||||||
|
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
|
||||||
|
normal: [0.88, 0.88, 0.88, 1.0],
|
||||||
|
low: [0.55, 0.55, 0.55, 1.0],
|
||||||
|
superior: [0.95, 0.95, 0.95, 1.0],
|
||||||
|
magic: [0.35, 0.52, 1.0, 1.0],
|
||||||
|
rare: [1.0, 0.92, 0.22, 1.0],
|
||||||
|
set: [0.12, 0.95, 0.12, 1.0],
|
||||||
|
unique: [0.85, 0.72, 0.42, 1.0],
|
||||||
|
craft: [1.0, 0.62, 0.12, 1.0],
|
||||||
|
rune: [1.0, 0.62, 0.12, 1.0],
|
||||||
|
gold: [1.0, 0.84, 0.2, 1.0],
|
||||||
|
}
|
||||||
|
|
||||||
|
export let globalItemsAtlasHandle: AtlasHandle | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load authentic 1.13c baked items atlas from /ui/items-atlas.png.
|
||||||
|
*/
|
||||||
|
export async function loadItemsAtlas(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
): Promise<AtlasHandle | undefined> {
|
||||||
|
if (globalItemsAtlasHandle !== undefined) return globalItemsAtlasHandle
|
||||||
|
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return undefined
|
||||||
|
const candidates = [
|
||||||
|
'/ui/items-atlas.png',
|
||||||
|
packBase ? `${packBase}/ui/items-atlas.png` : '',
|
||||||
|
'ui/items-atlas.png',
|
||||||
|
'public/ui/items-atlas.png',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const url of candidates) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url)
|
||||||
|
if (!resp.ok) continue
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const bitmap = await createImageBitmap(blob)
|
||||||
|
try {
|
||||||
|
const handle = renderer.addAtlas(bitmap, BAKED_UI_MANIFEST.atlasWidth, BAKED_UI_MANIFEST.atlasHeight)
|
||||||
|
globalItemsAtlasHandle = handle
|
||||||
|
return handle
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the authentic Blizzard flippy ground sprite rect for a ground item entity.
|
||||||
|
*/
|
||||||
|
export function resolveGroundItemFlippyFrame(item: GroundItemEntity): SpriteRect | null {
|
||||||
|
const target = item.isGold
|
||||||
|
? { isGold: true, amount: item.amount, code: 'gld' }
|
||||||
|
: (item.item || (item as any))
|
||||||
|
|
||||||
|
return resolveGroundItemSpriteRect(
|
||||||
|
target,
|
||||||
|
BAKED_UI_MANIFEST.flippyRects,
|
||||||
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronize HUD ground drops state if needed.
|
||||||
|
*/
|
||||||
|
export function syncHudGroundDrops(
|
||||||
|
_engine: GameEngine,
|
||||||
|
hudManager: HudManager | null,
|
||||||
|
): void {
|
||||||
|
hudManager?.syncPublishedState?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a ground item entity with authentic Diablo II 1.13c appearance:
|
||||||
|
* - Parabolic bounce height offset (when dropping or inventory full)
|
||||||
|
* - Authentic Blizzard `flippyfile` (`flp*.dc6`) ground item sprite resting on the 2:1 isometric plane
|
||||||
|
* - Ground contact oval shadow
|
||||||
|
*/
|
||||||
|
export function drawGroundItem(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
item: GroundItemEntity,
|
||||||
|
now: number,
|
||||||
|
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
|
||||||
|
): void {
|
||||||
|
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
|
||||||
|
const baseY = item.y - bounceH
|
||||||
|
|
||||||
|
const isEthOrSockBase = !item.isGold &&
|
||||||
|
(item.quality === 'normal' || item.quality === 'superior' || item.quality === 'high' || item.quality === 'hi-quality') &&
|
||||||
|
!(item as any).isRuneword &&
|
||||||
|
!(item.item as any)?.isRuneword &&
|
||||||
|
isGroundItemEtherealOrSocketed(item)
|
||||||
|
|
||||||
|
const qColor = item.isGold
|
||||||
|
? GROUND_ITEM_QUALITY_COLORS.gold!
|
||||||
|
: isEthOrSockBase
|
||||||
|
? GROUND_ITEM_QUALITY_COLORS.low!
|
||||||
|
: (GROUND_ITEM_QUALITY_COLORS[item.quality] ?? GROUND_ITEM_QUALITY_COLORS.normal!)
|
||||||
|
|
||||||
|
// 1. Authentic Blizzard flippy ground sprite resolution (strictly fail-fast if missing)
|
||||||
|
const target = item.isGold
|
||||||
|
? { isGold: true, amount: item.amount, code: 'gld' }
|
||||||
|
: (item.item || (item as any))
|
||||||
|
|
||||||
|
const sr = resolveGroundItemSpriteRect(
|
||||||
|
target,
|
||||||
|
BAKED_UI_MANIFEST.flippyRects,
|
||||||
|
BAKED_UI_MANIFEST.codeToFlippyFile,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sr === null) {
|
||||||
|
throw new Error(
|
||||||
|
`GroundItem render failure: missing flippy sprite rect in BAKED_UI_MANIFEST for ground item "${item.name}" (id: "${item.id}")`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentic Diablo II ground item sprite icon:
|
||||||
|
// Natural sprite dimensions from flp*.dc6 (already rendered by Blizzard in 2:1 isometric perspective)
|
||||||
|
const baseW = sr.w
|
||||||
|
const baseH = sr.h
|
||||||
|
|
||||||
|
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
|
||||||
|
const shadowAlpha = bounceH > 0 ? Math.max(0.1, 0.38 * (1 - Math.min(1, bounceH / 45))) : 0.38
|
||||||
|
const shadowW = Math.max(12, Math.round(baseW * 0.95))
|
||||||
|
const shadowH = Math.max(4, Math.round(shadowW * 0.38))
|
||||||
|
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, shadowH, [0, 0, 0, shadowAlpha])
|
||||||
|
|
||||||
|
const drawX = Math.round(item.x - baseW / 2)
|
||||||
|
const drawY = Math.round(baseY - baseH)
|
||||||
|
|
||||||
|
const frame: AtlasFrame = {
|
||||||
|
x: sr.x,
|
||||||
|
y: sr.y,
|
||||||
|
width: sr.w,
|
||||||
|
height: sr.h,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof (renderer as any).draw === 'function') {
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
...(itemsAtlas !== undefined ? { atlas: itemsAtlas } : {}),
|
||||||
|
width: baseW,
|
||||||
|
height: baseH,
|
||||||
|
})
|
||||||
|
} else if (typeof (renderer as any).drawQuad === 'function') {
|
||||||
|
// Slanted quad projection fallback for tests expecting drawQuad
|
||||||
|
const groundH = Math.max(6, Math.round(baseH * 0.52))
|
||||||
|
const shearX = Math.round(-groundH * 0.55)
|
||||||
|
const x2 = item.x - baseW / 2
|
||||||
|
const y2 = baseY
|
||||||
|
const x3 = item.x + baseW / 2
|
||||||
|
const y3 = baseY
|
||||||
|
const x0 = x2 + shearX
|
||||||
|
const y0 = baseY - groundH
|
||||||
|
const x1 = x3 + shearX
|
||||||
|
const y1 = baseY - groundH
|
||||||
|
;(renderer as any).drawQuad(frame, x0, y0, x1, y1, x2, y2, x3, y3, {
|
||||||
|
atlas: itemsAtlas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import {
|
||||||
|
ORTHO_SUB_TILE_HEIGHT,
|
||||||
|
ORTHO_SUB_TILE_WIDTH,
|
||||||
|
type CollisionGrid,
|
||||||
|
} from '../../game/d2map.ts'
|
||||||
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
|
import type { Environment } from '../../game/engine/environment.ts'
|
||||||
|
import type { LightGrid } from '../../game/engine/light-grid.ts'
|
||||||
|
import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts'
|
||||||
|
import type { TownPortalSlot } from '../../game/portal.ts'
|
||||||
|
import { getMissileTxtData } from '../../game/skills.ts'
|
||||||
|
import { getTblLang, type TblLang } from '../../i18n/lang.ts'
|
||||||
|
import type { SpriteRenderer } from '../../render/renderer.ts'
|
||||||
|
import type { ObjectDrawable } from './object-animation.ts'
|
||||||
|
import type { ActSceneState } from './scene-state.ts'
|
||||||
|
|
||||||
|
export type LightingPreset =
|
||||||
|
| 'auto'
|
||||||
|
| 'noon'
|
||||||
|
| 'morning'
|
||||||
|
| 'sunset'
|
||||||
|
| 'night'
|
||||||
|
| 'cave'
|
||||||
|
| 'hell'
|
||||||
|
| 'eclipse'
|
||||||
|
| 'off'
|
||||||
|
|
||||||
|
export interface LightingPresetConfig {
|
||||||
|
readonly id: LightingPreset
|
||||||
|
readonly labelZh: string
|
||||||
|
readonly labelEn: string
|
||||||
|
readonly enabled: boolean
|
||||||
|
readonly overrideAmbient?: {
|
||||||
|
readonly intensity: number
|
||||||
|
readonly red: number
|
||||||
|
readonly green: number
|
||||||
|
readonly blue: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIGHTING_PRESETS: Readonly<Record<LightingPreset, LightingPresetConfig>> = {
|
||||||
|
auto: {
|
||||||
|
id: 'auto',
|
||||||
|
labelZh: '自动 (原版 1.13c)',
|
||||||
|
labelEn: 'Auto (Original 1.13c)',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
noon: {
|
||||||
|
id: 'noon',
|
||||||
|
labelZh: '正午 (明亮日光)',
|
||||||
|
labelEn: 'Noon (Bright Daylight)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 255, red: 255, green: 255, blue: 255 },
|
||||||
|
},
|
||||||
|
morning: {
|
||||||
|
id: 'morning',
|
||||||
|
labelZh: '清晨 (晨曦暖金)',
|
||||||
|
labelEn: 'Morning (Warm Dawn)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 190, red: 225, green: 198, blue: 145 },
|
||||||
|
},
|
||||||
|
sunset: {
|
||||||
|
id: 'sunset',
|
||||||
|
labelZh: '黄昏 (暮色紫金)',
|
||||||
|
labelEn: 'Sunset (Dusk Purple-Gold)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 145, red: 210, green: 155, blue: 198 },
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
id: 'night',
|
||||||
|
labelZh: '午夜 (幽暗蓝月)',
|
||||||
|
labelEn: 'Midnight (Moonlit Blue)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 38, red: 115, green: 140, blue: 240 },
|
||||||
|
},
|
||||||
|
cave: {
|
||||||
|
id: 'cave',
|
||||||
|
labelZh: '深渊地牢 (火炬暗黑)',
|
||||||
|
labelEn: 'Deep Dungeon (Torchlight)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 5, red: 185, green: 175, blue: 165 },
|
||||||
|
},
|
||||||
|
hell: {
|
||||||
|
id: 'hell',
|
||||||
|
labelZh: '地狱血月 (第四幕猩红)',
|
||||||
|
labelEn: 'Hell Blood Moon (Act IV Crimson)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 72, red: 255, green: 62, blue: 52 },
|
||||||
|
},
|
||||||
|
eclipse: {
|
||||||
|
id: 'eclipse',
|
||||||
|
labelZh: '蚀日之咒 (幽蓝暗蚀)',
|
||||||
|
labelEn: 'Solar Eclipse (Dark Blue)',
|
||||||
|
enabled: true,
|
||||||
|
overrideAmbient: { intensity: 28, red: 45, green: 85, blue: 245 },
|
||||||
|
},
|
||||||
|
off: {
|
||||||
|
id: 'off',
|
||||||
|
labelZh: '关闭光照 (全亮无光影)',
|
||||||
|
labelEn: 'Lighting Off (Full Bright)',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lightingLabel(
|
||||||
|
cfg: LightingPresetConfig,
|
||||||
|
lang: TblLang = getTblLang(),
|
||||||
|
): string {
|
||||||
|
return lang === 'ENG' ? cfg.labelEn : cfg.labelZh
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLightingPreset(raw: string | null | undefined): LightingPreset {
|
||||||
|
const clean = (raw ?? '').trim().toLowerCase()
|
||||||
|
if (clean === '0' || clean === 'false' || clean === 'off' || clean === 'none') return 'off'
|
||||||
|
if (clean === '1' || clean === 'true' || clean === 'auto' || clean === '') return 'auto'
|
||||||
|
if (clean in LIGHTING_PRESETS) return clean as LightingPreset
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneLightingPassContext {
|
||||||
|
readonly lightingEnabled: boolean
|
||||||
|
readonly currentLightingPreset: LightingPreset
|
||||||
|
readonly playerSubTile: () => { x: number; y: number }
|
||||||
|
readonly lightGrid: LightGrid
|
||||||
|
readonly environment: Environment
|
||||||
|
readonly runtime: {
|
||||||
|
readonly levelId: number
|
||||||
|
readonly grid: CollisionGrid
|
||||||
|
readonly objectDrawables?: readonly ObjectDrawable[]
|
||||||
|
}
|
||||||
|
readonly engine: GameEngine
|
||||||
|
readonly portalSlot: TownPortalSlot
|
||||||
|
readonly renderer: SpriteRenderer
|
||||||
|
readonly state: ActSceneState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute and upload the 1.13c sub-tile lightmap pass for the active frame.
|
||||||
|
*/
|
||||||
|
export function applySceneLightingPass(ctx: SceneLightingPassContext): void {
|
||||||
|
const {
|
||||||
|
lightingEnabled,
|
||||||
|
currentLightingPreset,
|
||||||
|
playerSubTile,
|
||||||
|
lightGrid,
|
||||||
|
environment,
|
||||||
|
runtime,
|
||||||
|
engine,
|
||||||
|
portalSlot,
|
||||||
|
renderer,
|
||||||
|
state,
|
||||||
|
} = ctx
|
||||||
|
|
||||||
|
if (lightingEnabled) {
|
||||||
|
const pSub = playerSubTile()
|
||||||
|
lightGrid.centerOnSubTile(pSub.x, pSub.y)
|
||||||
|
lightGrid.clear()
|
||||||
|
lightGrid.updateObstacles(runtime.grid)
|
||||||
|
|
||||||
|
const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
|
||||||
|
const overrideAmb = presetCfg.overrideAmbient
|
||||||
|
const rawIntensity = overrideAmb !== undefined ? overrideAmb.intensity : environment.currentIntensity
|
||||||
|
const isDarkCave = rawIntensity <= 10 || currentLightingPreset === 'cave'
|
||||||
|
|
||||||
|
const flicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0
|
||||||
|
const basePlayerRadius = isDarkCave ? 20 : 22
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: pSub.x,
|
||||||
|
subTileY: pSub.y,
|
||||||
|
radius: basePlayerRadius + flicker,
|
||||||
|
intensity: 242,
|
||||||
|
red: 255,
|
||||||
|
green: 234,
|
||||||
|
blue: 196,
|
||||||
|
})
|
||||||
|
|
||||||
|
const skillToMissile: Readonly<Record<string, string>> = {
|
||||||
|
'36': 'firebolt',
|
||||||
|
'38': 'chargedbolt',
|
||||||
|
'39': 'icebolt',
|
||||||
|
'45': 'iceblast',
|
||||||
|
'47': 'fireball',
|
||||||
|
'49': 'lightning',
|
||||||
|
'53': 'chainlightning',
|
||||||
|
'55': 'glacialspike',
|
||||||
|
'56': 'meteor',
|
||||||
|
'64': 'frozenorb',
|
||||||
|
'67': 'teeth',
|
||||||
|
'84': 'bonespear',
|
||||||
|
'93': 'bonespirit',
|
||||||
|
}
|
||||||
|
for (const shot of engine.projectiles) {
|
||||||
|
const mType = shot.missileType ?? (shot.skillId ? skillToMissile[String(shot.skillId)] : undefined)
|
||||||
|
if (!mType) continue
|
||||||
|
const mData = getMissileTxtData(mType)
|
||||||
|
const lightUnits = mData.light > 0 ? mData.light : (mData.trans === 1 ? 6 : 0)
|
||||||
|
if (lightUnits > 0) {
|
||||||
|
const dx = (shot.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
|
const dy = (shot.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
|
const shotSubX = Math.round((dy + dx) / 2)
|
||||||
|
const shotSubY = Math.round((dy - dx) / 2)
|
||||||
|
const isCold = mData.blue > mData.red
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: shotSubX,
|
||||||
|
subTileY: shotSubY,
|
||||||
|
radius: Math.max(14, lightUnits * 2),
|
||||||
|
intensity: 250,
|
||||||
|
red: isCold ? Math.max(120, mData.red) : mData.red,
|
||||||
|
green: isCold ? Math.max(175, mData.green) : mData.green,
|
||||||
|
blue: mData.blue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const exp of engine.explosions) {
|
||||||
|
const expData = getMissileTxtData(exp.missileType)
|
||||||
|
const lightUnits = expData && expData.light > 0 ? expData.light : 8
|
||||||
|
const dx = (exp.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
|
const dy = (exp.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
|
const expSubX = Math.round((dy + dx) / 2)
|
||||||
|
const expSubY = Math.round((dy - dx) / 2)
|
||||||
|
const isColdExp =
|
||||||
|
exp.missileType.includes('ice') ||
|
||||||
|
exp.missileType.includes('freeze') ||
|
||||||
|
exp.missileType.includes('blizzard') ||
|
||||||
|
exp.missileType.includes('orb')
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: expSubX,
|
||||||
|
subTileY: expSubY,
|
||||||
|
radius: Math.round(lightUnits * 2.5),
|
||||||
|
intensity: 255,
|
||||||
|
red: isColdExp ? 130 : (expData?.red ?? 255),
|
||||||
|
green: isColdExp ? 185 : (expData?.green ?? 160),
|
||||||
|
blue: isColdExp ? 255 : (expData?.blue ?? 50),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime.objectDrawables) {
|
||||||
|
for (const obj of runtime.objectDrawables) {
|
||||||
|
const tok = (obj.token ?? '').trim().toUpperCase()
|
||||||
|
const isTorchOrFire =
|
||||||
|
tok === 'TO' || tok === 'T1' || tok === 'T2' || tok === 'E1' || tok === 'E2' ||
|
||||||
|
tok === 'V1' || tok === 'V2' || tok === 'JT' || tok === 'UX' || tok === 'FB' ||
|
||||||
|
tok === 'B1' || tok === 'B2' || tok === 'B3' || tok === 'CF' || tok === 'SS' ||
|
||||||
|
tok === 'F8' || tok === '1Y' || tok === 'FL'
|
||||||
|
const isWaypoint =
|
||||||
|
tok === 'WP' || tok === 'WI' || tok === 'WM' || tok === 'WN' ||
|
||||||
|
tok === 'W7' || tok === 'WZ' || tok === 'WV' || tok === 'AY'
|
||||||
|
const isShrineOrBeam =
|
||||||
|
tok === 'SH' || tok === 'S0' || tok === 'HA' || tok === '1G' ||
|
||||||
|
obj.blendMode === 'additive' || (tok !== '' && ADDITIVE_OBJECT_TOKENS.has(tok))
|
||||||
|
|
||||||
|
if (isTorchOrFire || isWaypoint || isShrineOrBeam) {
|
||||||
|
const dx = obj.x - engine.world.player.x
|
||||||
|
const dy = obj.y - engine.world.player.y
|
||||||
|
if (dx * dx + dy * dy < 950 * 950) {
|
||||||
|
const odx = (obj.x - runtime.grid.originX) / ORTHO_SUB_TILE_WIDTH
|
||||||
|
const ody = (obj.y - runtime.grid.originY) / ORTHO_SUB_TILE_HEIGHT
|
||||||
|
const objSubX = Math.round((ody + odx) / 2)
|
||||||
|
const objSubY = Math.round((ody - odx) / 2)
|
||||||
|
const objFlicker = isTorchOrFire ? ((Math.sin(engine.world.tick * 0.45 + obj.x * 0.05) * 1.2) | 0) : 0
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: objSubX,
|
||||||
|
subTileY: objSubY,
|
||||||
|
radius: (isTorchOrFire ? 13 : isWaypoint ? 14 : 11) + objFlicker,
|
||||||
|
intensity: isTorchOrFire ? 175 : 160,
|
||||||
|
red: isWaypoint ? 120 : 255,
|
||||||
|
green: isWaypoint ? 150 : (isTorchOrFire ? 195 : 205),
|
||||||
|
blue: isWaypoint ? 255 : (isTorchOrFire ? 115 : 175),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePortalMouthForLight = portalSlot.mouthInLevel(runtime.levelId)
|
||||||
|
if (activePortalMouthForLight !== null) {
|
||||||
|
const portalFlicker = (Math.sin(engine.world.tick * 0.35) * 1.5) | 0
|
||||||
|
lightGrid.addLight({
|
||||||
|
subTileX: activePortalMouthForLight.x,
|
||||||
|
subTileY: activePortalMouthForLight.y,
|
||||||
|
radius: 19 + portalFlicker,
|
||||||
|
intensity: 235,
|
||||||
|
red: 64,
|
||||||
|
green: 110,
|
||||||
|
blue: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveAmbIntensity = rawIntensity > 0 ? rawIntensity : 5
|
||||||
|
const effR = overrideAmb !== undefined ? overrideAmb.red : (rawIntensity > 0 ? environment.currentRed : 185)
|
||||||
|
const effG = overrideAmb !== undefined ? overrideAmb.green : (rawIntensity > 0 ? environment.currentGreen : 175)
|
||||||
|
const effB = overrideAmb !== undefined ? overrideAmb.blue : (rawIntensity > 0 ? environment.currentBlue : 165)
|
||||||
|
const ambNorm = effectiveAmbIntensity / 255
|
||||||
|
const ambR = (effR / 255) * ambNorm
|
||||||
|
const ambG = (effG / 255) * ambNorm
|
||||||
|
const ambB = (effB / 255) * ambNorm
|
||||||
|
|
||||||
|
renderer.setLighting({
|
||||||
|
ambient: [ambR, ambG, ambB],
|
||||||
|
originSubX: lightGrid.originSubX,
|
||||||
|
originSubY: lightGrid.originSubY,
|
||||||
|
sceneOriginX: runtime.grid.originX,
|
||||||
|
sceneOriginY: runtime.grid.originY,
|
||||||
|
lightmap: lightGrid.exportTextureBuffer(),
|
||||||
|
})
|
||||||
|
|
||||||
|
state.lightingPreset = currentLightingPreset
|
||||||
|
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
|
||||||
|
state.ambientIntensity = effectiveAmbIntensity
|
||||||
|
state.ambientColor = [effR, effG, effB]
|
||||||
|
} else {
|
||||||
|
renderer.setLighting(null)
|
||||||
|
const presetCfg = LIGHTING_PRESETS[currentLightingPreset]
|
||||||
|
state.lightingPreset = currentLightingPreset
|
||||||
|
state.lightingMode = getTblLang() === 'ENG' ? presetCfg.labelEn : presetCfg.labelZh
|
||||||
|
state.ambientIntensity = 255
|
||||||
|
state.ambientColor = [255, 255, 255]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,664 @@
|
||||||
|
import type { ActiveExplosion } from '../../game/engine.ts'
|
||||||
|
import {
|
||||||
|
getMissileTxtData,
|
||||||
|
type ActiveOverlay,
|
||||||
|
type Projectile,
|
||||||
|
} from '../../game/skills.ts'
|
||||||
|
import type { AtlasFrame } from '../../render/atlas.ts'
|
||||||
|
import {
|
||||||
|
LightningTrailManager,
|
||||||
|
drawElectricBoltFallback,
|
||||||
|
} from '../../render/missile-renderer.ts'
|
||||||
|
import {
|
||||||
|
MISSILE_METAS,
|
||||||
|
velocityToDccDirection,
|
||||||
|
type MissileFrameTuple,
|
||||||
|
type MissileMeta,
|
||||||
|
} from '../../render/missiles-meta.ts'
|
||||||
|
import {
|
||||||
|
OVERLAY_METAS,
|
||||||
|
type OverlayMeta,
|
||||||
|
} from '../../render/overlays-meta.ts'
|
||||||
|
import type {
|
||||||
|
AtlasHandle,
|
||||||
|
SpriteRenderer,
|
||||||
|
} from '../../render/renderer.ts'
|
||||||
|
|
||||||
|
export {
|
||||||
|
LightningTrailManager,
|
||||||
|
MISSILE_METAS,
|
||||||
|
OVERLAY_METAS,
|
||||||
|
drawElectricBoltFallback,
|
||||||
|
getMissileTxtData,
|
||||||
|
velocityToDccDirection,
|
||||||
|
type MissileFrameTuple,
|
||||||
|
type MissileMeta,
|
||||||
|
type OverlayMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packed missile animation asset loaded into GPU memory.
|
||||||
|
*/
|
||||||
|
export interface LoadedMissileArt {
|
||||||
|
readonly meta: MissileMeta
|
||||||
|
readonly handle: AtlasHandle
|
||||||
|
readonly frames: AtlasFrame[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase.
|
||||||
|
*/
|
||||||
|
export async function loadMissileArtMap(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
): Promise<Map<string, LoadedMissileArt>> {
|
||||||
|
const map = new Map<string, LoadedMissileArt>()
|
||||||
|
if (typeof fetch === 'undefined') return map
|
||||||
|
const targets = Array.from(new Set(Object.values(MISSILE_METAS).map(m => m.name)))
|
||||||
|
const baseCandidates = [
|
||||||
|
'/missiles',
|
||||||
|
packBase ? `${packBase}/missiles` : '',
|
||||||
|
'samples/d2-packs/missiles',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targets.map(async name => {
|
||||||
|
for (const base of baseCandidates) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
fetch(`${base}/${name}.json`),
|
||||||
|
fetch(`${base}/${name}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as MissileMeta
|
||||||
|
const blob = await pngResp.blob()
|
||||||
|
const bitmap = await createImageBitmap(blob)
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
map.set(name, { meta, handle, frames })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore candidate error and continue to next fallback path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Map all alias keys to the loaded art handle to prevent duplicate atlas uploads
|
||||||
|
for (const [alias, meta] of Object.entries(MISSILE_METAS)) {
|
||||||
|
const art = map.get(meta.name)
|
||||||
|
if (art && !map.has(alias)) {
|
||||||
|
map.set(alias, art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.has('icebolt')) {
|
||||||
|
const iceboltArt = map.get('icebolt')!
|
||||||
|
map.set('frozenorbbolt', iceboltArt)
|
||||||
|
map.set('frozenorbnova', iceboltArt)
|
||||||
|
}
|
||||||
|
if (map.has('meteorexplode')) {
|
||||||
|
const meteorArt = map.get('meteorexplode')!
|
||||||
|
if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt)
|
||||||
|
}
|
||||||
|
if (map.has('firebolt')) {
|
||||||
|
const fireboltArt = map.get('firebolt')!
|
||||||
|
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
||||||
|
if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt)
|
||||||
|
}
|
||||||
|
if (map.has('blizzard3') && !map.has('blizzard4')) {
|
||||||
|
map.set('blizzard4', map.get('blizzard3')!)
|
||||||
|
}
|
||||||
|
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
|
||||||
|
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
|
||||||
|
}
|
||||||
|
if (map.has('lightningstrike')) {
|
||||||
|
const ltArt = map.get('lightningstrike')!
|
||||||
|
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
|
||||||
|
if (!map.has('lightning')) map.set('lightning', ltArt)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Diablo II 1.13c missile projectile:
|
||||||
|
* - Uses authentic baked DCC sprite atlas when available.
|
||||||
|
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
|
||||||
|
*/
|
||||||
|
export function drawMissileProjectile(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
shot: Projectile,
|
||||||
|
missileArt?: LoadedMissileArt,
|
||||||
|
missileArtMap?: Map<string, LoadedMissileArt>,
|
||||||
|
): void {
|
||||||
|
if (shot.missileType === 'blizzardcenter') {
|
||||||
|
return // Invisible controller missile
|
||||||
|
}
|
||||||
|
|
||||||
|
const altitude = shot.altitude ?? 0
|
||||||
|
|
||||||
|
if (missileArt !== undefined) {
|
||||||
|
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
||||||
|
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
||||||
|
|
||||||
|
let frameIndex: number
|
||||||
|
if (missileArt.meta.name === 'meteorcenter') {
|
||||||
|
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
|
||||||
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
||||||
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||||
|
} else if (
|
||||||
|
(missileArt.meta.name === 'meteorfire' ||
|
||||||
|
missileArt.meta.name === 'firewall' ||
|
||||||
|
missileArt.meta.name === 'groundfirebig') &&
|
||||||
|
frameCount === 37
|
||||||
|
) {
|
||||||
|
// For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
||||||
|
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
||||||
|
// Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade.
|
||||||
|
const defaultDuration =
|
||||||
|
missileArt.meta.name === 'meteorfire'
|
||||||
|
? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15
|
||||||
|
: shot.ttl > 0
|
||||||
|
? (shot.ageTicks ?? 0) + shot.ttl
|
||||||
|
: 90
|
||||||
|
const duration = shot.fireDuration ?? defaultDuration
|
||||||
|
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
||||||
|
const EXTINCTION_TICKS = 12
|
||||||
|
if (shot.ttl <= EXTINCTION_TICKS) {
|
||||||
|
frameIndex = Math.max(0, Math.min(11, shot.ttl - 1))
|
||||||
|
} else {
|
||||||
|
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
|
||||||
|
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
||||||
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const isGroundFlame =
|
||||||
|
missileArt.meta.name === 'meteorfire' ||
|
||||||
|
missileArt.meta.name === 'firewall' ||
|
||||||
|
missileArt.meta.name === 'groundfirebig'
|
||||||
|
const isExtinguishing = isGroundFlame && shot.ttl <= 12
|
||||||
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
||||||
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
||||||
|
const opacity = isExtinguishing ? decayRatio : 1.0
|
||||||
|
|
||||||
|
const drawW = frame.width * scale
|
||||||
|
const drawH = frame.height * scale
|
||||||
|
const baseAnchorX = frame.anchorX !== undefined ? frame.anchorX : -frame.width / 2
|
||||||
|
const baseAnchorY = frame.anchorY !== undefined ? frame.anchorY : -frame.height / 2
|
||||||
|
const drawX = shot.x + baseAnchorX * scale
|
||||||
|
const drawY = (shot.y + (baseAnchorY + frame.height) * scale - drawH) - altitude
|
||||||
|
|
||||||
|
const isAdditive = getMissileTxtData(shot.missileType ?? missileArt.meta.name).trans === 1
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: missileArt.handle,
|
||||||
|
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||||
|
...(isExtinguishing
|
||||||
|
? {
|
||||||
|
width: drawW,
|
||||||
|
height: drawH,
|
||||||
|
tint: [1, 1, 1, opacity],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Dual Sprite Falling Trajectory & Rendering:
|
||||||
|
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
|
||||||
|
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
|
||||||
|
if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) {
|
||||||
|
const remainingTicks = shot.ttl
|
||||||
|
const fallAltitude = remainingTicks * 25
|
||||||
|
const slideX = -remainingTicks * 15
|
||||||
|
const meteorX = shot.x + slideX
|
||||||
|
const meteorY = shot.y
|
||||||
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
||||||
|
|
||||||
|
// 1. Atmospheric flame tail (`meteortail`, ID 102)
|
||||||
|
const tailArt = missileArtMap.get('meteortail')
|
||||||
|
if (tailArt !== undefined) {
|
||||||
|
const tailGroup = tailArt.frames[0]
|
||||||
|
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
|
||||||
|
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
|
||||||
|
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
|
||||||
|
if (tailFrame !== undefined) {
|
||||||
|
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
|
||||||
|
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
|
||||||
|
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
|
||||||
|
atlas: tailArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Falling meteor core fireball (`meteor`, ID 100)
|
||||||
|
const meteorArt = missileArtMap.get('meteor')
|
||||||
|
if (meteorArt !== undefined) {
|
||||||
|
const meteorGroup = meteorArt.frames[0]
|
||||||
|
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
|
||||||
|
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
|
||||||
|
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
|
||||||
|
if (meteorFrame !== undefined) {
|
||||||
|
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
|
||||||
|
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
|
||||||
|
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
|
||||||
|
atlas: meteorArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = shot.x
|
||||||
|
const y = shot.y - altitude
|
||||||
|
|
||||||
|
if (shot.missileType === 'meteorcenter') {
|
||||||
|
// Procedural ground reticle (2:1 isometric ground ellipse)
|
||||||
|
renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4])
|
||||||
|
renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6])
|
||||||
|
if (shot.ttl > 0) {
|
||||||
|
const remainingTicks = shot.ttl
|
||||||
|
const fallAltitude = remainingTicks * 25
|
||||||
|
const slideX = -remainingTicks * 15
|
||||||
|
const mx = shot.x + slideX
|
||||||
|
const my = shot.y - fallAltitude
|
||||||
|
// Falling fireball core
|
||||||
|
renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8])
|
||||||
|
renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95])
|
||||||
|
// Trailing flame tail
|
||||||
|
renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
||||||
|
const age = shot.ageTicks ?? 0
|
||||||
|
const wobble = Math.sin(age * 0.25 + (shot.headIndex ?? 0) * 2.1) * 3
|
||||||
|
const swayX = Math.cos(age * 0.18 + (shot.headIndex ?? 0) * 1.7) * 2.5
|
||||||
|
|
||||||
|
// 1. Ground magma / lava fire pit (2:1 isometric ground ellipse base)
|
||||||
|
renderer.drawSolid(x - 14, y - 5, 28, 10, [0.8, 0.2, 0.02, 0.5])
|
||||||
|
renderer.drawSolid(x - 9, y - 3, 18, 6, [1.0, 0.5, 0.08, 0.75])
|
||||||
|
renderer.drawSolid(x - 4, y - 2, 8, 4, [1.0, 0.9, 0.3, 0.9])
|
||||||
|
|
||||||
|
// 2. Serpentine segmented neck rising from the lava pit
|
||||||
|
const seg1Y = y - 7 + wobble * 0.3
|
||||||
|
const seg1X = x + swayX * 0.3
|
||||||
|
renderer.drawSolid(seg1X - 5, seg1Y - 3, 10, 6, [0.85, 0.28, 0.05, 0.85])
|
||||||
|
renderer.drawSolid(seg1X - 3, seg1Y - 2, 6, 4, [1.0, 0.6, 0.15, 0.9])
|
||||||
|
|
||||||
|
const seg2Y = y - 14 + wobble * 0.6
|
||||||
|
const seg2X = x + swayX * 0.7
|
||||||
|
renderer.drawSolid(seg2X - 4, seg2Y - 3, 8, 6, [0.9, 0.35, 0.05, 0.85])
|
||||||
|
renderer.drawSolid(seg2X - 2, seg2Y - 2, 4, 4, [1.0, 0.7, 0.2, 0.9])
|
||||||
|
|
||||||
|
// 3. Serpent head crown with flaming mane & glowing eyes
|
||||||
|
const headY = y - 22 + wobble
|
||||||
|
const headX = x + swayX
|
||||||
|
// Flaming crest / mane
|
||||||
|
renderer.drawSolid(headX - 6, headY - 8, 12, 12, [1.0, 0.35, 0.05, 0.6])
|
||||||
|
renderer.drawSolid(headX - 4, headY - 6, 8, 9, [1.0, 0.65, 0.15, 0.85])
|
||||||
|
renderer.drawSolid(headX - 2, headY - 4, 4, 6, [1.0, 0.95, 0.4, 0.95])
|
||||||
|
// Dragon/serpent snout & jaw
|
||||||
|
renderer.drawSolid(headX - 3, headY, 6, 4, [0.8, 0.2, 0.05, 0.9])
|
||||||
|
// Glowing incandescent eye
|
||||||
|
renderer.drawSolid(headX - 1, headY - 2, 2, 2, [1.0, 1.0, 0.9, 1.0])
|
||||||
|
|
||||||
|
// 4. If missile sprite art is available (firebolt/hydra), render the fiery head sprite over the crest
|
||||||
|
const art = missileArtMap?.get('hydra') ?? missileArtMap?.get('firebolt')
|
||||||
|
if (art !== undefined) {
|
||||||
|
const group = art.frames[0]
|
||||||
|
const frameCount = Math.max(1, art.meta.framesPerDirection)
|
||||||
|
const frameIndex = Math.floor((age * art.meta.animSpeed) / 16) % frameCount
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? headX + frame.anchorX : headX - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? headY + frame.anchorY : headY - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: art.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
shot.missileType === 'meteorfire' ||
|
||||||
|
shot.missileType === 'firewall' ||
|
||||||
|
shot.missileType === 'groundfirebig'
|
||||||
|
) {
|
||||||
|
const isExtinguishing = shot.ttl <= 12
|
||||||
|
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
||||||
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
||||||
|
const alpha = isExtinguishing ? decayRatio : 1.0
|
||||||
|
renderer.drawSolid(x - 12 * scale, y - 6 * scale, 24 * scale, 12 * scale, [1.0, 0.35, 0.05, 0.5 * alpha])
|
||||||
|
renderer.drawSolid(x - 8 * scale, y - 4 * scale, 16 * scale, 8 * scale, [1.0, 0.65, 0.1, 0.7 * alpha])
|
||||||
|
renderer.drawSolid(x - 4 * scale, y - 2 * scale, 8 * scale, 4 * scale, [1.0, 0.95, 0.3, 0.9 * alpha])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const vx = shot.vx
|
||||||
|
const vy = shot.vy
|
||||||
|
const speed = Math.hypot(vx, vy)
|
||||||
|
const dirX = speed > 0 ? vx / speed : 1
|
||||||
|
const dirY = speed > 0 ? vy / speed : 0
|
||||||
|
|
||||||
|
const isLightningMissile =
|
||||||
|
shot.missileType === 'lightningbolt' ||
|
||||||
|
shot.missileType === 'chainlightning' ||
|
||||||
|
shot.missileType === 'lightning' ||
|
||||||
|
shot.missileType === 'lightninghit'
|
||||||
|
|
||||||
|
if (isLightningMissile) {
|
||||||
|
drawElectricBoltFallback(renderer, x, y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Dynamic flame trail: trailing flame and ember puffs along reverse trajectory
|
||||||
|
for (let i = 1; i <= 5; i += 1) {
|
||||||
|
const dist = i * 4.2
|
||||||
|
// Sinusoidal organic wobble perpendicular to travel direction
|
||||||
|
const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75)
|
||||||
|
const px = x - dirX * dist - dirY * flicker
|
||||||
|
const py = y - dirY * dist + dirX * flicker
|
||||||
|
const size = Math.max(3, 8 - i)
|
||||||
|
let color: readonly [number, number, number, number]
|
||||||
|
if (i === 1) color = [1.0, 0.72, 0.16, 0.78]
|
||||||
|
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
|
||||||
|
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
|
||||||
|
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
|
||||||
|
else color = [0.45, 0.1, 0.02, 0.18]
|
||||||
|
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Wide soft orange-red heat haze / aura
|
||||||
|
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
|
||||||
|
|
||||||
|
// 3. Medium bright flame body
|
||||||
|
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55])
|
||||||
|
|
||||||
|
// 4. Inner intense fire
|
||||||
|
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
|
||||||
|
|
||||||
|
// 5. Incandescent white-hot center core
|
||||||
|
renderer.drawSolid(x - 2, y - 2, 4, 4, [1.0, 1.0, 0.88, 0.98])
|
||||||
|
}
|
||||||
|
|
||||||
|
export const drawFireboltProjectile = drawMissileProjectile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Diablo II 1.13c style explosion:
|
||||||
|
* - Uses authentic baked DCC sprite atlas when available.
|
||||||
|
* - Falls back to procedural explosion in test/offline environments.
|
||||||
|
*/
|
||||||
|
export function drawExplosion(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
exp: ActiveExplosion,
|
||||||
|
explosionArt?: LoadedMissileArt,
|
||||||
|
): void {
|
||||||
|
if (explosionArt !== undefined) {
|
||||||
|
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
|
||||||
|
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
|
||||||
|
if (explosionArt.meta.directions > 1) {
|
||||||
|
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
|
||||||
|
const group = explosionArt.frames[dir]
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: explosionArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const group = explosionArt.frames[0]
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: explosionArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
|
||||||
|
const inv = 1 - progress
|
||||||
|
const x = exp.x
|
||||||
|
const y = exp.y
|
||||||
|
|
||||||
|
// 1. Central expanding fireball burst
|
||||||
|
const radius = 6 + progress * 20
|
||||||
|
const alpha = inv * 0.85
|
||||||
|
const r = 1.0
|
||||||
|
const g = progress < 0.25 ? 0.92 - progress * 1.2 : 0.6 * inv
|
||||||
|
const b = progress < 0.2 ? 0.7 * inv : 0.04
|
||||||
|
renderer.drawSolid(x - radius, y - radius, radius * 2, radius * 2, [r, g, b, alpha * 0.35])
|
||||||
|
|
||||||
|
const innerRadius = radius * 0.62
|
||||||
|
renderer.drawSolid(x - innerRadius, y - innerRadius, innerRadius * 2, innerRadius * 2, [r, Math.min(1, g + 0.25), b * 1.4, alpha * 0.7])
|
||||||
|
|
||||||
|
// Early flash at impact point (frames 0..3)
|
||||||
|
if (progress < 0.3) {
|
||||||
|
const flashSize = Math.max(3, 7 * (1 - progress * 3.3))
|
||||||
|
renderer.drawSolid(x - flashSize, y - flashSize, flashSize * 2, flashSize * 2, [1.0, 1.0, 0.92, 0.95])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Eight radial fire sparks/embers flying outwards
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const angle = i * (Math.PI / 4) + i * 0.16
|
||||||
|
const sparkDist = progress * 28 + (i % 3) * 3
|
||||||
|
const sx = x + Math.cos(angle) * sparkDist
|
||||||
|
const sy = y + Math.sin(angle) * sparkDist
|
||||||
|
const sparkSize = Math.max(2, 4.5 * inv)
|
||||||
|
renderer.drawSolid(sx - sparkSize / 2, sy - sparkSize / 2, sparkSize, sparkSize, [1.0, 0.55 * inv + 0.2, 0.05, inv * 0.9])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const drawFireExplosion = drawExplosion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packed overlay animation asset loaded into GPU memory.
|
||||||
|
*/
|
||||||
|
export interface LoadedOverlayArt {
|
||||||
|
readonly meta: OverlayMeta
|
||||||
|
readonly handle: AtlasHandle
|
||||||
|
readonly frames: AtlasFrame[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load authentic 1.13c baked cast overlay atlases from /overlays/*.png or packBase.
|
||||||
|
*/
|
||||||
|
export async function loadOverlayArtMap(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
packBase?: string,
|
||||||
|
): Promise<Map<string, LoadedOverlayArt>> {
|
||||||
|
const map = new Map<string, LoadedOverlayArt>()
|
||||||
|
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return map
|
||||||
|
const targets = Array.from(new Set(Object.values(OVERLAY_METAS).map(m => m.name)))
|
||||||
|
const baseCandidates = [
|
||||||
|
'/overlays',
|
||||||
|
packBase ? `${packBase}/overlays` : '',
|
||||||
|
'samples/d2-packs/overlays',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
targets.map(async name => {
|
||||||
|
for (const base of baseCandidates) {
|
||||||
|
try {
|
||||||
|
const [jsonResp, pngResp] = await Promise.all([
|
||||||
|
fetch(`${base}/${name}.json`),
|
||||||
|
fetch(`${base}/${name}.png`),
|
||||||
|
])
|
||||||
|
if (jsonResp.ok && pngResp.ok) {
|
||||||
|
const meta = (await jsonResp.json()) as OverlayMeta
|
||||||
|
const blob = await pngResp.blob()
|
||||||
|
const bitmap = await createImageBitmap(blob)
|
||||||
|
let handle: AtlasHandle
|
||||||
|
try {
|
||||||
|
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
||||||
|
} finally {
|
||||||
|
bitmap.close()
|
||||||
|
}
|
||||||
|
const frames: AtlasFrame[][] = meta.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
...(anchorX !== undefined ? { anchorX } : {}),
|
||||||
|
...(anchorY !== undefined ? { anchorY } : {}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const art: LoadedOverlayArt = { meta, handle, frames }
|
||||||
|
map.set(name, art)
|
||||||
|
if (meta.celFile) {
|
||||||
|
map.set(meta.celFile.toLowerCase(), art)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore candidate error and continue to next fallback path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
for (const [alias, meta] of Object.entries(OVERLAY_METAS)) {
|
||||||
|
const art = map.get(meta.name)
|
||||||
|
if (art && !map.has(alias)) {
|
||||||
|
map.set(alias, art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Diablo II 1.13c character cast overlay effect:
|
||||||
|
* - Uses authentic baked DCC sprite atlas with additive blending (Trans === 3) when available.
|
||||||
|
* - Respects frame anchorX and anchorY offsets to align with caster's origin.
|
||||||
|
* - Falls back to procedural magic glow in test/offline environments.
|
||||||
|
* - Dynamic lighting/light mask rendering deferred to issue #395.
|
||||||
|
*/
|
||||||
|
export function drawCastOverlay(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
overlay: ActiveOverlay,
|
||||||
|
overlayArt?: LoadedOverlayArt,
|
||||||
|
): void {
|
||||||
|
// 1. Sprite animation with additive blending
|
||||||
|
if (overlayArt !== undefined) {
|
||||||
|
const frameCount = Math.max(1, overlayArt.meta.framesPerDirection || overlayArt.meta.frames)
|
||||||
|
const frameIndex = Math.min(Math.max(0, Math.floor(overlay.frame)), frameCount - 1)
|
||||||
|
const group = overlayArt.frames[0]
|
||||||
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? overlay.x + frame.anchorX : overlay.x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? overlay.y + frame.anchorY : overlay.y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, { atlas: overlayArt.handle, blendMode: 'additive' })
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback procedural representation in test/offline environments
|
||||||
|
const [r255, g255, b255] = overlay.lightColor
|
||||||
|
const r = r255 / 255
|
||||||
|
const g = g255 / 255
|
||||||
|
const b = b255 / 255
|
||||||
|
const fallbackRadius = 16
|
||||||
|
renderer.drawSolid(
|
||||||
|
overlay.x - fallbackRadius,
|
||||||
|
overlay.y - fallbackRadius,
|
||||||
|
fallbackRadius * 2,
|
||||||
|
fallbackRadius * 2,
|
||||||
|
[r, g, b, 0.6],
|
||||||
|
{ blendMode: 'additive' },
|
||||||
|
)
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an active Diablo II 1.13c Town Portal (`Objects.txt` ID 59, Token `TP`):
|
||||||
|
* - Plays 15-frame opening sequence (`OP`: `tptrlitophth` + `tphdlitophth`) at 200/256 * 25 fps (51.2 ms/frame, 768 ms total),
|
||||||
|
* then loops 15-frame operating sequence (`ON`: `tptrlitonhth` + `tphdlitonhth`).
|
||||||
|
* - Draws `TR` ground shadow layer with normal blending and `HD` blue vortex layer with additive blending.
|
||||||
|
* - Falls back to a procedural blue portal vortex oval in unit test / offline environments.
|
||||||
|
*/
|
||||||
|
export function drawTownPortal(
|
||||||
|
renderer: SpriteRenderer,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
elapsedMs: number,
|
||||||
|
overlayArtMap: ReadonlyMap<string, LoadedOverlayArt>,
|
||||||
|
): void {
|
||||||
|
const frameDurationMs = 51.2 // 1000 / (25 * 200 / 256)
|
||||||
|
const openDurationMs = 15 * frameDurationMs // 768 ms
|
||||||
|
const isOpening = elapsedMs < openDurationMs
|
||||||
|
const trArt = overlayArtMap.get(isOpening ? 'town_portal_op_tr' : 'town_portal_on_tr')
|
||||||
|
const hdArt = overlayArtMap.get(isOpening ? 'town_portal_op_hd' : 'town_portal_on_hd')
|
||||||
|
const rawFrameIdx = isOpening
|
||||||
|
? Math.min(14, Math.max(0, Math.floor(elapsedMs / frameDurationMs)))
|
||||||
|
: Math.max(0, Math.floor((elapsedMs - openDurationMs) / frameDurationMs)) % 15
|
||||||
|
|
||||||
|
let drewSprite = false
|
||||||
|
if (trArt !== undefined) {
|
||||||
|
const group = trArt.frames[0]
|
||||||
|
const frame = group?.[rawFrameIdx] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: trArt.handle,
|
||||||
|
blendMode: 'normal',
|
||||||
|
})
|
||||||
|
drewSprite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hdArt !== undefined) {
|
||||||
|
const group = hdArt.frames[0]
|
||||||
|
const frame = group?.[rawFrameIdx] ?? group?.[0]
|
||||||
|
if (frame !== undefined) {
|
||||||
|
const drawX = frame.anchorX !== undefined ? x + frame.anchorX : x - frame.width / 2
|
||||||
|
const drawY = frame.anchorY !== undefined ? y + frame.anchorY : y - frame.height / 2
|
||||||
|
renderer.draw(frame, drawX, drawY, {
|
||||||
|
atlas: hdArt.handle,
|
||||||
|
blendMode: 'additive',
|
||||||
|
elevationY: Math.max(0, frame.height - 16),
|
||||||
|
})
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
drewSprite = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!drewSprite) {
|
||||||
|
renderer.drawSolid(x - 18, y - 82, 36, 82, [0.22, 0.48, 1.0, 0.75], { blendMode: 'additive' })
|
||||||
|
renderer.setBlendMode('normal')
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { AnimatableTile, AnimatedFrame } from '../../game/animated-tiles.ts'
|
||||||
|
import { ADDITIVE_OBJECT_TOKENS } from '../../game/objects.ts'
|
||||||
|
import type { AtlasFrame } from '../../render/atlas.ts'
|
||||||
|
|
||||||
|
/** One tile to draw. */
|
||||||
|
export interface Drawable extends AnimatableTile {
|
||||||
|
frame: AtlasFrame
|
||||||
|
readonly x: number
|
||||||
|
readonly y: number
|
||||||
|
readonly cellX: number
|
||||||
|
readonly cellY: number
|
||||||
|
/** Atlas page index this frame lives on. */
|
||||||
|
page: number
|
||||||
|
readonly animatedFrames?: readonly AnimatedFrame[] | undefined
|
||||||
|
readonly roofHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One placed frame of an animated object. */
|
||||||
|
export interface AnimatedObjectFrame {
|
||||||
|
readonly frame: AtlasFrame
|
||||||
|
readonly page: number
|
||||||
|
readonly offsetX: number
|
||||||
|
readonly offsetY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One object sprite to draw. */
|
||||||
|
export interface ObjectDrawable {
|
||||||
|
readonly id?: number | undefined
|
||||||
|
readonly objectsTxtId?: number | undefined
|
||||||
|
readonly token?: string | undefined
|
||||||
|
readonly name?: string | null | undefined
|
||||||
|
readonly kind?: 'object' | 'npc' | undefined
|
||||||
|
readonly blendMode?: 'normal' | 'additive' | undefined
|
||||||
|
readonly trans?: number | undefined
|
||||||
|
frame: AtlasFrame
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
readonly depth: number
|
||||||
|
/** Object atlas page index this frame lives on. */
|
||||||
|
page: number
|
||||||
|
readonly baseX?: number | undefined
|
||||||
|
readonly baseY?: number | undefined
|
||||||
|
readonly animatedFrames?: readonly AnimatedObjectFrame[] | undefined
|
||||||
|
readonly frameDurationMs?: number | undefined
|
||||||
|
readonly cycleAnim?: boolean | undefined
|
||||||
|
readonly phaseOffsetMs?: number | undefined
|
||||||
|
currentFrameIndex?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that represent environmental additive-blend illumination and magic objects.
|
||||||
|
* Ground truth parity (1.13c): Window light rays (11, 12 in Act 4), light beams
|
||||||
|
* (R1..R5, 1R..4R with Trans=7), arcane orifice (HA with Trans=1), and fire jets.
|
||||||
|
*/
|
||||||
|
export { ADDITIVE_OBJECT_TOKENS }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens that represent persistent environmental fire and illumination objects.
|
||||||
|
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
|
||||||
|
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
|
||||||
|
*/
|
||||||
|
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
|
||||||
|
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SplitMix64 based spatial phase offset for unsynchronized animated map objects.
|
||||||
|
* Prevents mechanical synchronization across multiple torches or campfires in a scene.
|
||||||
|
*
|
||||||
|
* @param x - object sub-tile or world x
|
||||||
|
* @param y - object sub-tile or world y
|
||||||
|
* @param totalDurationMs - total duration of one animation cycle in milliseconds
|
||||||
|
* @returns phase offset in milliseconds within [0, totalDurationMs)
|
||||||
|
*/
|
||||||
|
export function computeObjectPhaseOffsetMs(x: number, y: number, totalDurationMs: number): number {
|
||||||
|
if (totalDurationMs <= 0) return 0
|
||||||
|
let h = (Math.imul(x, 73856093) ^ Math.imul(y, 19349663)) >>> 0
|
||||||
|
h = (h ^ (h >>> 16)) >>> 0
|
||||||
|
h = Math.imul(h, 0x45d9f3b) >>> 0
|
||||||
|
h = (h ^ (h >>> 16)) >>> 0
|
||||||
|
return ((h % 1000000) / 1000000) * totalDurationMs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update one animated object's current frame based on time and spatial phase.
|
||||||
|
*
|
||||||
|
* @param obj - object drawable
|
||||||
|
* @param clockMs - current clock time in milliseconds
|
||||||
|
* @returns true if frame changed
|
||||||
|
*/
|
||||||
|
export function updateAnimatedObject(obj: ObjectDrawable, clockMs: number): boolean {
|
||||||
|
const frames = obj.animatedFrames
|
||||||
|
if (!frames || frames.length <= 1) return false
|
||||||
|
const duration = (obj.frameDurationMs && obj.frameDurationMs > 0) ? obj.frameDurationMs : 40
|
||||||
|
const phase = obj.phaseOffsetMs ?? 0
|
||||||
|
const elapsed = Math.max(0, clockMs + phase)
|
||||||
|
const isPersistent = obj.token !== undefined && PERSISTENT_ILLUMINATION_TOKENS.has(obj.token.trim().toUpperCase())
|
||||||
|
const cycleAnim = isPersistent || obj.cycleAnim !== false
|
||||||
|
const targetIndex = cycleAnim
|
||||||
|
? Math.floor(elapsed / duration) % frames.length
|
||||||
|
: Math.min(frames.length - 1, Math.floor(elapsed / duration))
|
||||||
|
|
||||||
|
if (targetIndex === obj.currentFrameIndex) return false
|
||||||
|
obj.currentFrameIndex = targetIndex
|
||||||
|
const targetFrame = frames[targetIndex]
|
||||||
|
if (targetFrame !== undefined) {
|
||||||
|
obj.frame = targetFrame.frame
|
||||||
|
obj.page = targetFrame.page
|
||||||
|
if (obj.baseX !== undefined && obj.baseY !== undefined) {
|
||||||
|
obj.x = obj.baseX + targetFrame.offsetX
|
||||||
|
obj.y = obj.baseY + targetFrame.offsetY
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an array of animated objects according to millisecond clock.
|
||||||
|
*
|
||||||
|
* @param objects - array of object drawables
|
||||||
|
* @param clockMs - current clock time in milliseconds
|
||||||
|
* @returns number of objects whose active frame changed
|
||||||
|
*/
|
||||||
|
export function updateAnimatedObjects(
|
||||||
|
objects: readonly ObjectDrawable[],
|
||||||
|
clockMs: number,
|
||||||
|
): number {
|
||||||
|
let updated = 0
|
||||||
|
for (let i = 0; i < objects.length; i += 1) {
|
||||||
|
if (updateAnimatedObject(objects[i]!, clockMs)) {
|
||||||
|
updated += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
import type { Monster } from '../../game/combat.ts'
|
||||||
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
|
import type { GroundItemEntity } from '../../game/ground-items.ts'
|
||||||
|
import { getLevelNames, levelDisplayName } from '../../game/level-names-zh.ts'
|
||||||
|
import { getTownNpcDescriptorByName, npcDisplayName } from '../../game/npc-table.ts'
|
||||||
|
import type { TownPortalSlot } from '../../game/portal.ts'
|
||||||
|
import { getTblLang } from '../../i18n/lang.ts'
|
||||||
|
import type { ViewportCamera } from '../../sim/camera.ts'
|
||||||
|
import {
|
||||||
|
computeInitialGroundLabelLayouts,
|
||||||
|
resolveLadderCollisions,
|
||||||
|
type GroundLabelOverlay,
|
||||||
|
} from '../../ui/ground-labels.ts'
|
||||||
|
import type { HudManager } from '../../ui/hud-manager.ts'
|
||||||
|
import {
|
||||||
|
getEntranceLabelWorldPos,
|
||||||
|
getWarpLabelWorldPos,
|
||||||
|
screenToWorld,
|
||||||
|
subTileToScene,
|
||||||
|
type SceneEntrance,
|
||||||
|
type SceneWarp,
|
||||||
|
} from '../backend/scene-links-nav.ts'
|
||||||
|
import { MARKER_HEIGHT, type MapRuntime } from './pack-loader.ts'
|
||||||
|
import {
|
||||||
|
monsterArtStatusText,
|
||||||
|
monsterPlanStatusText,
|
||||||
|
} from './scene-state.ts'
|
||||||
|
|
||||||
|
export { monsterArtStatusText, monsterPlanStatusText }
|
||||||
|
|
||||||
|
export interface NpcLabelProjectionParams {
|
||||||
|
npc: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
frame?: {
|
||||||
|
readonly width?: number
|
||||||
|
readonly height?: number
|
||||||
|
readonly offsetX?: number
|
||||||
|
readonly offsetY?: number
|
||||||
|
} | null | undefined
|
||||||
|
}
|
||||||
|
camX: number
|
||||||
|
camY: number
|
||||||
|
cameraZoom: number
|
||||||
|
canvas: { width: number; height: number }
|
||||||
|
rect: { left: number; top: number; width: number; height: number }
|
||||||
|
markerHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NpcLabelProjectionResult {
|
||||||
|
domX: number
|
||||||
|
domY: number
|
||||||
|
fontSize: number
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNpcLabel(params: NpcLabelProjectionParams): NpcLabelProjectionResult {
|
||||||
|
const markerHeight = params.markerHeight ?? MARKER_HEIGHT
|
||||||
|
const scaleX = params.canvas.width > 0 ? params.rect.width / params.canvas.width : 1
|
||||||
|
const scaleY = params.canvas.height > 0 ? params.rect.height / params.canvas.height : 1
|
||||||
|
|
||||||
|
const frame = params.npc.frame
|
||||||
|
const topX = frame != null
|
||||||
|
? params.npc.x + (frame.offsetX ?? 0) + (frame.width ?? 0) / 2
|
||||||
|
: params.npc.x
|
||||||
|
const topY = frame != null
|
||||||
|
? params.npc.y + (frame.offsetY ?? 0)
|
||||||
|
: params.npc.y - markerHeight
|
||||||
|
|
||||||
|
const domX = params.rect.left + params.rect.width * 0.5 + (topX - params.camX) * params.cameraZoom * scaleX
|
||||||
|
const domY = params.rect.top + params.rect.height * 0.5 + (topY - params.camY) * params.cameraZoom * scaleY
|
||||||
|
|
||||||
|
const visible = domX > -100 && domX < params.rect.width + 100 && domY > -100 && domY < params.rect.height + 100
|
||||||
|
const safeZoom = Math.max(0.001, Number.isFinite(params.cameraZoom) ? params.cameraZoom : 1)
|
||||||
|
const fontSize = Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(safeZoom))))
|
||||||
|
|
||||||
|
return { domX, domY: domY - 2, fontSize, visible }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play authentic Diablo II audio rejection feedback when inventory is full.
|
||||||
|
* Low descending refusal tone paired with delayed secondary touch landing thud.
|
||||||
|
*/
|
||||||
|
export function playInventoryFullFeedback(): void {
|
||||||
|
try {
|
||||||
|
const AudioCtx = typeof window !== 'undefined' ? (window.AudioContext || (window as any).webkitAudioContext) : null
|
||||||
|
if (AudioCtx) {
|
||||||
|
const ctx = new AudioCtx()
|
||||||
|
// 1. Refusal tone (low pitch descending rejection cue)
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
osc.type = 'sawtooth'
|
||||||
|
osc.frequency.setValueAtTime(175, ctx.currentTime)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(90, ctx.currentTime + 0.28)
|
||||||
|
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3)
|
||||||
|
osc.connect(gain)
|
||||||
|
gain.connect(ctx.destination)
|
||||||
|
osc.start()
|
||||||
|
osc.stop(ctx.currentTime + 0.3)
|
||||||
|
|
||||||
|
// 2. Delayed light ground touch thud upon primary bounce landing (~350ms)
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (ctx.state !== 'closed') {
|
||||||
|
const thudOsc = ctx.createOscillator()
|
||||||
|
const thudGain = ctx.createGain()
|
||||||
|
thudOsc.type = 'triangle'
|
||||||
|
thudOsc.frequency.setValueAtTime(110, ctx.currentTime)
|
||||||
|
thudOsc.frequency.exponentialRampToValueAtTime(45, ctx.currentTime + 0.08)
|
||||||
|
thudGain.gain.setValueAtTime(0.2, ctx.currentTime)
|
||||||
|
thudGain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1)
|
||||||
|
thudOsc.connect(thudGain)
|
||||||
|
thudGain.connect(ctx.destination)
|
||||||
|
thudOsc.start()
|
||||||
|
thudOsc.stop(ctx.currentTime + 0.1)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore closed audio context
|
||||||
|
}
|
||||||
|
}, 350)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// AudioContext unavailable or blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display prominent Diablo II style on-screen notification banner with auto fade-out.
|
||||||
|
*/
|
||||||
|
export function showNotification(message: string, durationMs = 2000, status?: HTMLElement): void {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = message
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
let banner = document.getElementById('d2-center-notification')
|
||||||
|
if (!banner && document.body) {
|
||||||
|
banner = document.createElement('div')
|
||||||
|
banner.id = 'd2-center-notification'
|
||||||
|
banner.style.position = 'fixed'
|
||||||
|
banner.style.top = '18%'
|
||||||
|
banner.style.left = '50%'
|
||||||
|
banner.style.transform = 'translateX(-50%)'
|
||||||
|
banner.style.color = '#e03030'
|
||||||
|
banner.style.fontFamily = 'var(--font-display, "D2Exocet", "Cinzel", "Times New Roman", "Liberation Serif", "Noto Serif", "Songti SC", "Noto Serif SC", serif)'
|
||||||
|
banner.style.fontSize = '20px'
|
||||||
|
banner.style.fontWeight = 'bold'
|
||||||
|
banner.style.textShadow = '0 0 8px rgba(0,0,0,0.9), 1px 1px 2px #000'
|
||||||
|
banner.style.pointerEvents = 'none'
|
||||||
|
banner.style.zIndex = '99999'
|
||||||
|
banner.style.transition = 'opacity 0.4s ease-out'
|
||||||
|
document.body.appendChild(banner)
|
||||||
|
}
|
||||||
|
if (banner) {
|
||||||
|
banner.textContent = message
|
||||||
|
banner.style.opacity = '1'
|
||||||
|
clearTimeout((banner as any)._timeout)
|
||||||
|
;(banner as any)._timeout = setTimeout(() => {
|
||||||
|
if (banner) banner.style.opacity = '0'
|
||||||
|
}, durationMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
;(globalThis as unknown as {
|
||||||
|
__d2PlayInventoryFullFeedback?: () => void
|
||||||
|
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
|
||||||
|
}).__d2PlayInventoryFullFeedback = playInventoryFullFeedback
|
||||||
|
;(globalThis as unknown as {
|
||||||
|
__d2ShowNotification?: (message: string, durationMs?: number, status?: unknown) => void
|
||||||
|
}).__d2ShowNotification = (message: string, durationMs = 2000, status?: unknown) => {
|
||||||
|
showNotification(message, durationMs, status as HTMLElement | undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allocateLabelElement(
|
||||||
|
container: HTMLElement,
|
||||||
|
labelPool: HTMLDivElement[],
|
||||||
|
): HTMLDivElement {
|
||||||
|
let label: HTMLDivElement
|
||||||
|
if (typeof document !== 'undefined' && typeof document.createElement === 'function') {
|
||||||
|
label = document.createElement('div')
|
||||||
|
} else {
|
||||||
|
label = {
|
||||||
|
tagName: 'DIV',
|
||||||
|
textContent: '',
|
||||||
|
hidden: false,
|
||||||
|
className: '',
|
||||||
|
dataset: {},
|
||||||
|
style: {},
|
||||||
|
} as unknown as HTMLDivElement
|
||||||
|
}
|
||||||
|
label.style.position = 'absolute'
|
||||||
|
label.style.transform = 'translate(-50%, -100%)'
|
||||||
|
label.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
||||||
|
label.style.whiteSpace = 'nowrap'
|
||||||
|
label.style.pointerEvents = 'none'
|
||||||
|
labelPool.push(label)
|
||||||
|
if (typeof container.appendChild === 'function') {
|
||||||
|
container.appendChild(label)
|
||||||
|
} else if (Array.isArray((container as any).children)) {
|
||||||
|
;(container as any).children.push(label)
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverheadLabelsControllerHost {
|
||||||
|
readonly labelsContainer?: HTMLElement | null | undefined
|
||||||
|
readonly canvas: HTMLCanvasElement
|
||||||
|
readonly camera: ViewportCamera
|
||||||
|
readonly engine: GameEngine
|
||||||
|
readonly hudManager: HudManager | null
|
||||||
|
readonly portalSlot?: TownPortalSlot | undefined
|
||||||
|
readonly labelPool: HTMLDivElement[]
|
||||||
|
lastClientPos: { x: number; y: number } | null
|
||||||
|
hoveredMonster: Monster | null
|
||||||
|
hoveredGroundItem: GroundItemEntity | null
|
||||||
|
hoveredWarp: SceneWarp | null
|
||||||
|
hoveredEntrance: SceneEntrance | null
|
||||||
|
getRuntime(): MapRuntime
|
||||||
|
findMonsterAtWorld(worldX: number, worldY: number): Monster | null
|
||||||
|
syncLinkHoverAtWorld(worldX: number, worldY: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render recycled overhead labels for on-screen NPCs, Town Portals,
|
||||||
|
* `runtime.warps`, and `runtime.entrances` into `labelsContainer`.
|
||||||
|
*/
|
||||||
|
export function updateOverheadLabels(
|
||||||
|
host: OverheadLabelsControllerHost,
|
||||||
|
camX = host.engine.world.player.x,
|
||||||
|
camY = host.engine.world.player.y - 16,
|
||||||
|
activePortalMouthOverride?: { readonly x: number; readonly y: number; readonly toLevelId: number } | null,
|
||||||
|
activePortalPosOverride?: { readonly x: number; readonly y: number } | null,
|
||||||
|
): void {
|
||||||
|
const container = host.labelsContainer
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const runtime = host.getRuntime()
|
||||||
|
let labelCount = 0
|
||||||
|
const rect = host.canvas.getBoundingClientRect()
|
||||||
|
const scaleX = host.canvas.width > 0 ? rect.width / host.canvas.width : 1
|
||||||
|
const scaleY = host.canvas.height > 0 ? rect.height / host.canvas.height : 1
|
||||||
|
const zoom = host.camera?.zoom ?? 1
|
||||||
|
const fontSizePx = `${Math.min(24, Math.max(11, Math.round(13 * Math.sqrt(Math.max(0.001, zoom)))))}px`
|
||||||
|
|
||||||
|
if (host.lastClientPos !== null) {
|
||||||
|
let hudIntercepted = false
|
||||||
|
if (host.hudManager !== null) {
|
||||||
|
const logical = host.hudManager.clientToLogical(host.lastClientPos.x, host.lastClientPos.y)
|
||||||
|
hudIntercepted = host.hudManager.isPointInterceptedByHud(logical.x, logical.y)
|
||||||
|
}
|
||||||
|
if (hudIntercepted) {
|
||||||
|
host.hoveredMonster = null
|
||||||
|
host.hoveredGroundItem = null
|
||||||
|
host.hoveredWarp = null
|
||||||
|
host.hoveredEntrance = null
|
||||||
|
if (host.canvas?.style) {
|
||||||
|
host.canvas.style.cursor = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const worldAtPointer = screenToWorld({
|
||||||
|
clientX: host.lastClientPos.x,
|
||||||
|
clientY: host.lastClientPos.y,
|
||||||
|
rect,
|
||||||
|
canvas: host.canvas,
|
||||||
|
camX,
|
||||||
|
camY,
|
||||||
|
cameraZoom: zoom,
|
||||||
|
})
|
||||||
|
host.hoveredMonster = host.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
|
||||||
|
host.hoveredGroundItem = host.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
|
||||||
|
host.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeNpcMenuName = host.hudManager?.worldPanels?.npcMenu?.npcName?.toLowerCase()
|
||||||
|
const currentLang = getTblLang()
|
||||||
|
const actIndexForNpc = Math.max(0, (runtime?.act ?? 1) - 1)
|
||||||
|
for (const npc of host.engine.npcEntities ?? []) {
|
||||||
|
if (npc.def.name.toLowerCase() === 'dummy') continue
|
||||||
|
const npcDesc = getTownNpcDescriptorByName(npc.def.name, actIndexForNpc)
|
||||||
|
const npcLabelText = npcDesc ? npcDisplayName(npcDesc, currentLang) : npc.def.name
|
||||||
|
if (
|
||||||
|
activeNpcMenuName &&
|
||||||
|
(npc.def.name.toLowerCase() === activeNpcMenuName || npcLabelText.toLowerCase() === activeNpcMenuName)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const topX = npc.frame ? npc.x + (npc.frame.offsetX ?? 0) + (npc.frame.width ?? 0) / 2 : npc.x
|
||||||
|
const topY = npc.frame ? npc.y + (npc.frame.offsetY ?? 0) : npc.y - MARKER_HEIGHT
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
|
||||||
|
|
||||||
|
if (domX <= -100 || domX >= rect.width + 100) continue
|
||||||
|
if (domY <= -100 || domY >= rect.height + 100) continue
|
||||||
|
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
||||||
|
if (label.textContent !== npcLabelText) label.textContent = npcLabelText
|
||||||
|
label.className = 'd2-overhead-label d2-npc-label'
|
||||||
|
if (label.dataset) {
|
||||||
|
label.dataset.kind = 'npc'
|
||||||
|
delete label.dataset.toLevelId
|
||||||
|
delete label.dataset.hovered
|
||||||
|
}
|
||||||
|
label.style.color = '#c8a15a'
|
||||||
|
label.style.background = 'transparent'
|
||||||
|
label.style.border = 'none'
|
||||||
|
label.style.padding = '0'
|
||||||
|
label.style.borderRadius = '0'
|
||||||
|
label.style.fontWeight = 'normal'
|
||||||
|
label.style.boxShadow = 'none'
|
||||||
|
label.style.left = `${domX}px`
|
||||||
|
label.style.top = `${domY - 2}px`
|
||||||
|
label.style.fontSize = fontSizePx
|
||||||
|
label.hidden = false
|
||||||
|
labelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const portalMouth = activePortalMouthOverride !== undefined
|
||||||
|
? activePortalMouthOverride
|
||||||
|
: (host.portalSlot && runtime ? host.portalSlot.mouthInLevel(runtime.levelId) : null)
|
||||||
|
const portalPos = activePortalPosOverride !== undefined
|
||||||
|
? activePortalPosOverride
|
||||||
|
: (portalMouth && runtime?.grid ? subTileToScene(portalMouth.x, portalMouth.y, runtime.grid) : null)
|
||||||
|
|
||||||
|
if (portalMouth !== null && portalPos !== null) {
|
||||||
|
const topX = portalPos.x
|
||||||
|
const topY = portalPos.y - 94
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (topX - camX) * zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (topY - camY) * zoom * scaleY
|
||||||
|
if (domX > -100 && domX < rect.width + 100 && domY > -100 && domY < rect.height + 100) {
|
||||||
|
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
||||||
|
const portalLabelText = currentLang === 'ENG'
|
||||||
|
? `Town Portal -> ${levelDisplayName(portalMouth.toLevelId, 'ENG')}`
|
||||||
|
: `传送门 -> ${getLevelNames(portalMouth.toLevelId).nameZh}`
|
||||||
|
if (label.textContent !== portalLabelText) label.textContent = portalLabelText
|
||||||
|
label.className = 'd2-overhead-label d2-portal-label'
|
||||||
|
if (label.dataset) {
|
||||||
|
label.dataset.kind = 'portal'
|
||||||
|
label.dataset.toLevelId = String(portalMouth.toLevelId)
|
||||||
|
delete label.dataset.hovered
|
||||||
|
}
|
||||||
|
label.style.color = '#6cb4ff'
|
||||||
|
label.style.background = 'transparent'
|
||||||
|
label.style.border = 'none'
|
||||||
|
label.style.padding = '0'
|
||||||
|
label.style.borderRadius = '0'
|
||||||
|
label.style.fontWeight = 'normal'
|
||||||
|
label.style.boxShadow = 'none'
|
||||||
|
label.style.left = `${domX}px`
|
||||||
|
label.style.top = `${domY - 2}px`
|
||||||
|
label.style.fontSize = fontSizePx
|
||||||
|
label.hidden = false
|
||||||
|
labelCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtime?.grid) {
|
||||||
|
for (const warp of runtime.warps ?? []) {
|
||||||
|
if (!(warp.toLevelId > 0)) continue
|
||||||
|
const pos = getWarpLabelWorldPos(warp, runtime.grid)
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
|
||||||
|
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
|
||||||
|
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
||||||
|
const badgeText = currentLang === 'ENG'
|
||||||
|
? `To ${levelDisplayName(warp.toLevelId, 'ENG')}`
|
||||||
|
: `通往 ${getLevelNames(warp.toLevelId).nameZh}`
|
||||||
|
if (label.textContent !== badgeText) label.textContent = badgeText
|
||||||
|
const isHovered = host.hoveredWarp === warp
|
||||||
|
label.className = isHovered
|
||||||
|
? 'd2-overhead-label d2-exit-badge is-hovered'
|
||||||
|
: 'd2-overhead-label d2-exit-badge'
|
||||||
|
if (label.dataset) {
|
||||||
|
label.dataset.kind = 'warp'
|
||||||
|
label.dataset.toLevelId = String(warp.toLevelId)
|
||||||
|
label.dataset.hovered = isHovered ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
|
||||||
|
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
|
||||||
|
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
|
||||||
|
label.style.padding = '2px 7px'
|
||||||
|
label.style.borderRadius = '3px'
|
||||||
|
label.style.fontWeight = isHovered ? 'bold' : 'normal'
|
||||||
|
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
|
||||||
|
label.style.left = `${domX}px`
|
||||||
|
label.style.top = `${domY - 2}px`
|
||||||
|
label.style.fontSize = fontSizePx
|
||||||
|
label.hidden = false
|
||||||
|
labelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entrance of runtime.entrances ?? []) {
|
||||||
|
if (!(entrance.toLevelId > 0)) continue
|
||||||
|
const pos = getEntranceLabelWorldPos(entrance, runtime.grid)
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (pos.x - camX) * zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (pos.y - camY) * zoom * scaleY
|
||||||
|
if (domX <= -100 || domX >= rect.width + 100 || domY <= -100 || domY >= rect.height + 100) continue
|
||||||
|
const label = host.labelPool[labelCount] ?? allocateLabelElement(container, host.labelPool)
|
||||||
|
const badgeText = currentLang === 'ENG'
|
||||||
|
? `To ${levelDisplayName(entrance.toLevelId, 'ENG')}`
|
||||||
|
: `通往 ${getLevelNames(entrance.toLevelId).nameZh}`
|
||||||
|
if (label.textContent !== badgeText) label.textContent = badgeText
|
||||||
|
const isHovered = host.hoveredEntrance === entrance
|
||||||
|
label.className = isHovered
|
||||||
|
? 'd2-overhead-label d2-exit-badge is-hovered'
|
||||||
|
: 'd2-overhead-label d2-exit-badge'
|
||||||
|
if (label.dataset) {
|
||||||
|
label.dataset.kind = 'entrance'
|
||||||
|
label.dataset.toLevelId = String(entrance.toLevelId)
|
||||||
|
label.dataset.hovered = isHovered ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
label.style.color = isHovered ? '#fff3c4' : '#f0d080'
|
||||||
|
label.style.background = isHovered ? 'rgba(44, 32, 12, 0.92)' : 'rgba(12, 10, 8, 0.78)'
|
||||||
|
label.style.border = isHovered ? '1px solid #ffd86b' : '1px solid rgba(200, 161, 90, 0.65)'
|
||||||
|
label.style.padding = '2px 7px'
|
||||||
|
label.style.borderRadius = '3px'
|
||||||
|
label.style.fontWeight = isHovered ? 'bold' : 'normal'
|
||||||
|
label.style.boxShadow = isHovered ? '0 0 8px rgba(255, 210, 90, 0.65)' : 'none'
|
||||||
|
label.style.left = `${domX}px`
|
||||||
|
label.style.top = `${domY - 2}px`
|
||||||
|
label.style.fontSize = fontSizePx
|
||||||
|
label.hidden = false
|
||||||
|
labelCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = labelCount; index < host.labelPool.length; index += 1) {
|
||||||
|
const unused = host.labelPool[index]!
|
||||||
|
unused.hidden = true
|
||||||
|
if (unused.dataset && unused.dataset.hovered === 'true') {
|
||||||
|
unused.dataset.hovered = 'false'
|
||||||
|
}
|
||||||
|
if (unused.className.includes('is-hovered')) {
|
||||||
|
unused.className = unused.className.replace(/\s*is-hovered\b/g, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project and update floating ground item labels (Alt mode or single hovered item).
|
||||||
|
*/
|
||||||
|
export function updateGroundItemLabelsOverlay(
|
||||||
|
groundLabelOverlay: GroundLabelOverlay | null,
|
||||||
|
hudManager: HudManager | null,
|
||||||
|
mouseController: { hoveredGroundItem: GroundItemEntity | null } | null,
|
||||||
|
engine: GameEngine,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
camera: ViewportCamera,
|
||||||
|
camX: number,
|
||||||
|
camY: number,
|
||||||
|
): void {
|
||||||
|
if (groundLabelOverlay === null) return
|
||||||
|
const showLabels = Boolean(hudManager?.showGroundLabels)
|
||||||
|
let hoveredItem = mouseController?.hoveredGroundItem ?? null
|
||||||
|
if (hoveredItem !== null && !engine.groundItems.get(hoveredItem.id)) {
|
||||||
|
if (mouseController) mouseController.hoveredGroundItem = null
|
||||||
|
hoveredItem = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engine.groundItems.count > 0 && (showLabels || hoveredItem !== null)) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
|
||||||
|
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
|
||||||
|
const projectToScreen = (worldX: number, worldY: number) => {
|
||||||
|
const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX
|
||||||
|
const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY
|
||||||
|
if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { x: domX, y: domY }
|
||||||
|
}
|
||||||
|
if (showLabels) {
|
||||||
|
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
|
||||||
|
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
|
||||||
|
groundLabelOverlay.update(true, resolvedLayouts)
|
||||||
|
} else if (hoveredItem !== null) {
|
||||||
|
const initialLayouts = computeInitialGroundLabelLayouts([hoveredItem], projectToScreen)
|
||||||
|
groundLabelOverlay.update(true, initialLayouts)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groundLabelOverlay.update(false, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,406 @@
|
||||||
|
import { DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS } from '../../game/animated-tiles.ts'
|
||||||
|
import type { SorceressAppearanceSpec } from '../../game/char-appearance.ts'
|
||||||
|
import type { Monster, MonsterStats } from '../../game/combat.ts'
|
||||||
|
import { getEmbeddedDropTables } from '../../game/embedded-drop-tables.ts'
|
||||||
|
import type { NpcEntity } from '../../game/engine.ts'
|
||||||
|
import { enrichMonsterStats } from '../../game/monsters.ts'
|
||||||
|
import type { OpenPortal } from '../../game/portal.ts'
|
||||||
|
import { getTblLang, type TblLang } from '../../i18n/lang.ts'
|
||||||
|
import type {
|
||||||
|
AutomapOverlayLabel,
|
||||||
|
AutomapViewportMetrics,
|
||||||
|
Minimap,
|
||||||
|
} from '../../ui/minimap.ts'
|
||||||
|
import type { MonsterTargetHudInfo } from '../../ui/world-panels.ts'
|
||||||
|
import type { DensityMultiplier, EliteMultiplier } from '../backend/scene-population.ts'
|
||||||
|
import type { LightingPreset } from './lighting-presets.ts'
|
||||||
|
import type { MapRuntime } from './pack-loader.ts'
|
||||||
|
import { countLoadedPages } from './pack-loader.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `localStorage` key holding the single save slot.
|
||||||
|
*
|
||||||
|
* Deliberately the same key the retired `map.html` sandbox used, so a save made
|
||||||
|
* there still loads here — the snapshot format never differed, only the scene
|
||||||
|
* that drove it.
|
||||||
|
*/
|
||||||
|
export const SAVE_KEY = 'd2web.save'
|
||||||
|
/**
|
||||||
|
* How often the HUD text is rewritten, in milliseconds.
|
||||||
|
*
|
||||||
|
* The overlay reports averages, so refreshing it at frame rate only buys string
|
||||||
|
* churn: four `toFixed` calls and ten concatenations that the reader cannot
|
||||||
|
* follow anyway.
|
||||||
|
*/
|
||||||
|
export const HUD_INTERVAL_MS = 250
|
||||||
|
|
||||||
|
/** Live scene state, also published for automated checks. */
|
||||||
|
export interface ActSceneState {
|
||||||
|
ready: boolean
|
||||||
|
/** Where the map came from: a prebaked pack or the archives. */
|
||||||
|
source: 'pack' | 'live' | ''
|
||||||
|
act: number
|
||||||
|
level: string
|
||||||
|
variant: string
|
||||||
|
variants: string[]
|
||||||
|
/** @deprecated Use `variant` instead. */
|
||||||
|
quadrant: string
|
||||||
|
/** @deprecated Use `variants` instead. */
|
||||||
|
quadrants: string[]
|
||||||
|
/** The archive/pack location actually used. */
|
||||||
|
base: string
|
||||||
|
cellsX: number
|
||||||
|
cellsY: number
|
||||||
|
widthPx: number
|
||||||
|
heightPx: number
|
||||||
|
floors: number
|
||||||
|
shadows?: number
|
||||||
|
walls: number
|
||||||
|
frames: number
|
||||||
|
/** Atlas pages loaded and total. */
|
||||||
|
pagesLoaded: number
|
||||||
|
pagesTotal: number
|
||||||
|
/** Pages that had arrived when the first frame was drawn. */
|
||||||
|
pagesAtFirstFrame: number
|
||||||
|
/** Milliseconds from boot to the first drawn frame. */
|
||||||
|
firstFrameMs: number
|
||||||
|
/** Page indices the spawn viewport needed, for diagnostics. */
|
||||||
|
priorityPages: number[]
|
||||||
|
/** Draws skipped because their page had not arrived yet. */
|
||||||
|
skippedDraws: number
|
||||||
|
/** Objects the pack places (their art still needs the DCC/COF decoder). */
|
||||||
|
objects: number
|
||||||
|
/** Roof draws, painted in their own pass after the character. */
|
||||||
|
roofs: number
|
||||||
|
/** Roof opacity alpha (0.0..1.0), faded dynamically when player enters shelter. */
|
||||||
|
roofAlpha: number
|
||||||
|
/** Whether the composited Sorceress sprite is in use (else the marker). */
|
||||||
|
character: boolean
|
||||||
|
/** DCC members that went into the character sprite. */
|
||||||
|
characterMembers: number
|
||||||
|
/** Frames the character path has actually drawn; 0 while the marker is in use. */
|
||||||
|
characterFrames: number
|
||||||
|
/** Last group the character path selected (walk or stand, times 8 directions). */
|
||||||
|
characterGroup: number
|
||||||
|
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
|
||||||
|
heroToken?: string
|
||||||
|
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
|
||||||
|
heroName?: string
|
||||||
|
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
|
||||||
|
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
|
||||||
|
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs', 'stf', 'orb', 'bow', 'xbw'). */
|
||||||
|
weaponClass?: string | null | undefined
|
||||||
|
/** Current animation clip mode name of the player (e.g. 'nu', 'wl', 'rn', 'a1', 'sc'). */
|
||||||
|
playerClip?: string | null | undefined
|
||||||
|
/** Canonical appearance cache key for dynamic modular paperdoll compositing. */
|
||||||
|
characterAppearanceKey?: string | undefined
|
||||||
|
/** Active Sorceress 16-component appearance and palette shift spec. */
|
||||||
|
characterAppearance?: SorceressAppearanceSpec | undefined
|
||||||
|
/** Milliseconds from boot to the first rendered frame. */
|
||||||
|
loadMs: number
|
||||||
|
missing: number
|
||||||
|
walkable: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
facing: number
|
||||||
|
tick: number
|
||||||
|
tickRate: number
|
||||||
|
simTps: number
|
||||||
|
renderFps: number
|
||||||
|
fps: number
|
||||||
|
/**
|
||||||
|
* GPU draw calls the last frame actually issued.
|
||||||
|
*/
|
||||||
|
drawCalls: number
|
||||||
|
/** Quads the last frame submitted, i.e. what survived culling. */
|
||||||
|
quadsDrawn: number
|
||||||
|
/** Draws rejected last frame because their bounds fell outside the viewport. */
|
||||||
|
culledDraws: number
|
||||||
|
/** Wall-clock milliseconds the last `onRender` took. */
|
||||||
|
renderMs: number
|
||||||
|
/** Active lighting preset id ('auto', 'noon', 'morning', 'sunset', 'night', 'cave', 'hell', 'eclipse', 'off'). */
|
||||||
|
lightingPreset?: LightingPreset | undefined
|
||||||
|
/** Active lighting mode ('Glide 1.13c' or undefined). */
|
||||||
|
lightingMode?: string | undefined
|
||||||
|
/** Ambient light intensity [0..255]. */
|
||||||
|
ambientIntensity?: number | undefined
|
||||||
|
/** Ambient color [R, G, B] in [0..255]. */
|
||||||
|
ambientColor?: readonly [number, number, number] | undefined
|
||||||
|
/** Current zoom factor and the canvas backing-store size. */
|
||||||
|
zoom: number
|
||||||
|
canvasWidth: number
|
||||||
|
canvasHeight: number
|
||||||
|
error: string | null
|
||||||
|
dialog: string[]
|
||||||
|
npcs: number
|
||||||
|
npcsNear: NpcEntity[]
|
||||||
|
/** Count of animated tiles in the active map. */
|
||||||
|
animatedTiles: number
|
||||||
|
/** Count of animated map objects in the active map. */
|
||||||
|
animatedObjects: number
|
||||||
|
/** Animation speed from LvlPrest.txt `Animate` column (or undefined if not specified). */
|
||||||
|
animSpeed?: number | undefined
|
||||||
|
/** Per-frame duration in ms computed from animSpeed. */
|
||||||
|
frameDurationMs?: number | undefined
|
||||||
|
/** Monster ids the art loader could not dress, lower case. */
|
||||||
|
missingMonsterArt: string[]
|
||||||
|
/** Monster-art loads that threw: a whole level, or one monster type. */
|
||||||
|
monsterArtErrors: number
|
||||||
|
/** COF layers dropped inside art that did load: monsters missing body parts. */
|
||||||
|
monsterArtLayerFailures: number
|
||||||
|
/** Monsters the level's own plan produced. */
|
||||||
|
monstersPlanned: number
|
||||||
|
/** Active monster density multiplier (0.5, 1, 2, 4, 8). */
|
||||||
|
densityMultiplier?: DensityMultiplier | undefined
|
||||||
|
/** Active elite chance multiplier (0.5, 1, 2, 4, 'all'). */
|
||||||
|
eliteMultiplier?: EliteMultiplier | undefined
|
||||||
|
/** Dynamic monster re-budget and re-stream method without page reload. */
|
||||||
|
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||||
|
/** Active player Town Portal (if open). */
|
||||||
|
portal?: OpenPortal | null | undefined
|
||||||
|
/** Whether the automap/minimap is visible. */
|
||||||
|
minimapVisible?: boolean | undefined
|
||||||
|
/** Active automap mode ('minimap' | 'full'). */
|
||||||
|
automapMode?: 'minimap' | 'full' | undefined
|
||||||
|
/** Active automap projection divisor (20 for minimap, 10 for full). */
|
||||||
|
automapDivisor?: 10 | 20 | undefined
|
||||||
|
/** Number of loaded automap sprite atlases (0..8). */
|
||||||
|
automapAtlasesLoaded?: number | undefined
|
||||||
|
/** Pre-baked/active automap cell counts per layer. */
|
||||||
|
automapCellCounts?: {
|
||||||
|
floors: number
|
||||||
|
walls: number
|
||||||
|
objects: number
|
||||||
|
extras: number
|
||||||
|
total: number
|
||||||
|
} | undefined
|
||||||
|
/** Revealed DRLG room count for the active level. */
|
||||||
|
automapRevealedRooms?: number | undefined
|
||||||
|
/** Automap sprites drawn last frame per layer. */
|
||||||
|
automapSpritesDrawn?: {
|
||||||
|
floors: number
|
||||||
|
walls: number
|
||||||
|
objects: number
|
||||||
|
extras: number
|
||||||
|
portals: number
|
||||||
|
total: number
|
||||||
|
} | undefined
|
||||||
|
/** Automap 12-segment crosshairs drawn last frame. */
|
||||||
|
automapCrosshairsDrawn?: {
|
||||||
|
player: number
|
||||||
|
party: number
|
||||||
|
npcs: number
|
||||||
|
mercenaries: number
|
||||||
|
bosses: number
|
||||||
|
total: number
|
||||||
|
} | undefined
|
||||||
|
/** Revealed warp/stash Font6 labels on the automap. */
|
||||||
|
automapWarpLabels?: AutomapOverlayLabel[] | undefined
|
||||||
|
/** Top-right gold Font16 Level Name + Difficulty HUD readout. */
|
||||||
|
automapHudText?: {
|
||||||
|
levelName: string
|
||||||
|
difficulty: string
|
||||||
|
x: number
|
||||||
|
y1: number
|
||||||
|
y2: number
|
||||||
|
} | null | undefined
|
||||||
|
/** Active automap viewport metrics. */
|
||||||
|
automapViewport?: AutomapViewportMetrics | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** Act scene state for automation and debugging. */
|
||||||
|
__d2webAct?: ActSceneState
|
||||||
|
__d2webActs?: ActSceneState
|
||||||
|
__d2webMinimap?: Minimap
|
||||||
|
__d2webActScene?: {
|
||||||
|
switchHero: (token: string) => Promise<boolean>
|
||||||
|
getHero: () => string
|
||||||
|
switchLighting?: (preset: string) => LightingPreset
|
||||||
|
getLighting?: () => LightingPreset
|
||||||
|
reloadMonsters?: (options?: { density?: number | string; elite?: number | string }) => Promise<void> | void
|
||||||
|
setPlayers?: (count: number, partyCount?: number) => void
|
||||||
|
executeCommand?: (cmd: string) => boolean
|
||||||
|
castTownPortal?: () => boolean
|
||||||
|
enterTownPortal?: () => void
|
||||||
|
getPortal?: () => OpenPortal | null
|
||||||
|
getMinimap?: () => Minimap
|
||||||
|
syncAppearance?: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const state: ActSceneState = {
|
||||||
|
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
||||||
|
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, shadows: 0, walls: 0, frames: 0,
|
||||||
|
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||||
|
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||||
|
heroToken: 'so', heroName: 'Sorceress',
|
||||||
|
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
|
||||||
|
skippedDraws: 0, objects: 0, loadMs: 0,
|
||||||
|
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
|
||||||
|
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
||||||
|
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
||||||
|
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0, animatedObjects: 0,
|
||||||
|
animSpeed: undefined, frameDurationMs: DEFAULT_ANIMATED_TILE_FRAME_DURATION_MS,
|
||||||
|
missingMonsterArt: [], monsterArtErrors: 0, monsterArtLayerFailures: 0, monstersPlanned: 0,
|
||||||
|
densityMultiplier: 1, eliteMultiplier: 1,
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.__d2webAct = state
|
||||||
|
window.__d2webActs = state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
|
||||||
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
const stats =
|
||||||
|
monster.stats.isDemon === undefined &&
|
||||||
|
monster.stats.isUndead === undefined &&
|
||||||
|
monster.stats.nameKey === undefined
|
||||||
|
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
|
||||||
|
: monster.stats
|
||||||
|
const r = stats.rank
|
||||||
|
const statsAny = stats as MonsterStats & {
|
||||||
|
readonly descStr?: string
|
||||||
|
readonly subType?: string
|
||||||
|
readonly affixes?: readonly string[]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: stats.name || stats.id,
|
||||||
|
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
|
||||||
|
hp: monster.hp,
|
||||||
|
maxHp: stats.hp,
|
||||||
|
rank:
|
||||||
|
r === 'champion'
|
||||||
|
? 'champion'
|
||||||
|
: r === 'unique'
|
||||||
|
? 'unique'
|
||||||
|
: (r as string) === 'superunique'
|
||||||
|
? 'superunique'
|
||||||
|
: r === 'minion'
|
||||||
|
? 'minion'
|
||||||
|
: r === 'boss' || stats.isBoss === true
|
||||||
|
? 'boss'
|
||||||
|
: 'normal',
|
||||||
|
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
|
||||||
|
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
|
||||||
|
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
|
||||||
|
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
|
||||||
|
...(statsAny.descStr !== undefined ? { descStr: statsAny.descStr } : {}),
|
||||||
|
...(statsAny.subType !== undefined ? { subType: statsAny.subType } : {}),
|
||||||
|
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
|
||||||
|
...(statsAny.affixes !== undefined ? { affixes: statsAny.affixes } : {}),
|
||||||
|
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
|
||||||
|
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonsterArtDiagnosticState {
|
||||||
|
readonly missing: readonly string[]
|
||||||
|
readonly errors: number
|
||||||
|
readonly layerFailures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HUD fragment describing a monster-art load, empty when it was clean.
|
||||||
|
*
|
||||||
|
* @param load - the counters, from {@link ActSceneState} or {@link MonsterArtLoad}.
|
||||||
|
* @returns the fragment, ready to append to the HUD line.
|
||||||
|
*/
|
||||||
|
export function monsterArtStatusText(
|
||||||
|
load: MonsterArtDiagnosticState,
|
||||||
|
lang: TblLang = getTblLang(),
|
||||||
|
): string {
|
||||||
|
const isEn = lang === 'ENG'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (load.missing.length > 0) {
|
||||||
|
const shown = load.missing.slice(0, 3).join(isEn ? ', ' : '、')
|
||||||
|
const more = load.missing.length > 3 ? `…+${String(load.missing.length - 3)}` : ''
|
||||||
|
parts.push(
|
||||||
|
isEn
|
||||||
|
? `Monster art missing ${String(load.missing.length)} (red box: ${shown}${more})`
|
||||||
|
: `怪物美术缺失 ${String(load.missing.length)}(红方块:${shown}${more})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (load.errors > 0) parts.push(isEn ? `Load errors ${String(load.errors)}` : `加载错误 ${String(load.errors)}`)
|
||||||
|
if (load.layerFailures > 0) parts.push(isEn ? `Missing layers ${String(load.layerFailures)}` : `缺层 ${String(load.layerFailures)}`)
|
||||||
|
return parts.length === 0 ? '' : ` · ${parts.join(' · ')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HUD fragment describing a level's planned population.
|
||||||
|
*
|
||||||
|
* @param planned - how many monsters the plan produced.
|
||||||
|
* @returns the fragment, ready to append to the HUD line.
|
||||||
|
*/
|
||||||
|
export function monsterPlanStatusText(planned: number, lang: TblLang = getTblLang()): string {
|
||||||
|
if (lang === 'ENG') {
|
||||||
|
return planned === 0 ? ' · Monsters 0 (none planned for level)' : ` · Monsters ${String(planned)}`
|
||||||
|
}
|
||||||
|
return planned === 0 ? ' · 怪物 0(本关未规划到怪物)' : ` · 怪物 ${String(planned)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a level's planned population into {@link ActSceneState}.
|
||||||
|
*/
|
||||||
|
export function reportMonsterPlan(runtime: MapRuntime): void {
|
||||||
|
const planned = runtime.monsterPacks.reduce((sum, pack) => sum + pack.members.length, 0)
|
||||||
|
state.monstersPlanned = planned
|
||||||
|
if (planned === 0) {
|
||||||
|
console.info(`level ${runtime.level}: no monsters planned; the level stays empty`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a load's failures into {@link ActSceneState}.
|
||||||
|
*/
|
||||||
|
export function recordMonsterArtLoad(load: MonsterArtDiagnosticState): void {
|
||||||
|
state.missingMonsterArt = [...load.missing]
|
||||||
|
state.monsterArtErrors = load.errors
|
||||||
|
state.monsterArtLayerFailures = load.layerFailures
|
||||||
|
if (load.missing.length > 0 || load.errors > 0 || load.layerFailures > 0) {
|
||||||
|
console.error(
|
||||||
|
`monster art incomplete: ${String(load.missing.length)} type(s) without art `
|
||||||
|
+ `(${load.missing.join(', ')}), ${String(load.errors)} load error(s), `
|
||||||
|
+ `${String(load.layerFailures)} dropped layer(s)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a runtime's description into the published state.
|
||||||
|
*
|
||||||
|
* @param runtime - the runtime.
|
||||||
|
* @param base - location the data came from.
|
||||||
|
*/
|
||||||
|
export function publish(runtime: MapRuntime, base: string): void {
|
||||||
|
state.source = runtime.source
|
||||||
|
state.base = base
|
||||||
|
state.act = runtime.act
|
||||||
|
state.level = runtime.level
|
||||||
|
state.variant = runtime.variant
|
||||||
|
state.variants = [...runtime.variants]
|
||||||
|
state.quadrant = runtime.quadrant
|
||||||
|
state.quadrants = [...runtime.quadrants]
|
||||||
|
state.cellsX = runtime.cellsX
|
||||||
|
state.cellsY = runtime.cellsY
|
||||||
|
state.widthPx = runtime.widthPx
|
||||||
|
state.heightPx = runtime.heightPx
|
||||||
|
state.floors = runtime.floors.length
|
||||||
|
state.shadows = runtime.shadows?.length ?? 0
|
||||||
|
state.walls = runtime.walls.length
|
||||||
|
state.frames = runtime.frames
|
||||||
|
state.pagesTotal = runtime.pages.length
|
||||||
|
state.pagesLoaded = countLoadedPages(runtime.pages)
|
||||||
|
state.priorityPages = [...runtime.priorityPages]
|
||||||
|
state.objects = runtime.objects
|
||||||
|
state.roofs = runtime.roofs.length
|
||||||
|
state.animatedTiles = runtime.animatedDrawables?.length ?? 0
|
||||||
|
state.animatedObjects = runtime.animatedObjects?.length ?? 0
|
||||||
|
state.animSpeed = runtime.animSpeed
|
||||||
|
state.frameDurationMs = runtime.frameDurationMs
|
||||||
|
state.roofAlpha = 1
|
||||||
|
state.missing = 0
|
||||||
|
state.walkable = 1 - [...runtime.grid.blocked].reduce((sum, value) => sum + value, 0) / runtime.grid.blocked.length
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,858 @@
|
||||||
|
import type { Palette } from '../../formats/pal.ts'
|
||||||
|
import { WORLD_VARIANT_SEED } from '../../game/act-variants.ts'
|
||||||
|
import type { ActorAnimator } from '../../game/actor-animator.ts'
|
||||||
|
import {
|
||||||
|
SorceressLayerCompositor,
|
||||||
|
appearanceKey,
|
||||||
|
resolveSorceressAppearance,
|
||||||
|
} from '../../game/char-appearance.ts'
|
||||||
|
import type { CharacterClassCode } from '../../game/classes.ts'
|
||||||
|
import {
|
||||||
|
spawnMonsterPacks,
|
||||||
|
type MonsterPack,
|
||||||
|
type SafeZone,
|
||||||
|
} from '../../game/combat.ts'
|
||||||
|
import { cellCentre } from '../../game/d2map.ts'
|
||||||
|
import type { DropTables } from '../../game/drop-pipeline.ts'
|
||||||
|
import type { GameEngine } from '../../game/engine.ts'
|
||||||
|
import {
|
||||||
|
ACT_NAMES_EN,
|
||||||
|
ACT_NAMES_ZH,
|
||||||
|
getLevelNames,
|
||||||
|
sceneDisplayName,
|
||||||
|
variantDisplayLabel,
|
||||||
|
} from '../../game/level-names-zh.ts'
|
||||||
|
import type { LoadedMonsterArt } from '../../game/monster-art.ts'
|
||||||
|
import { MonsterStreamingManager } from '../../game/monster-streaming.ts'
|
||||||
|
import {
|
||||||
|
buildLangSwitchHref,
|
||||||
|
getTblLang,
|
||||||
|
type TblLang,
|
||||||
|
} from '../../i18n/lang.ts'
|
||||||
|
import type { AtlasFrame } from '../../render/atlas.ts'
|
||||||
|
import type { SpriteRenderer } from '../../render/renderer.ts'
|
||||||
|
import { cleanChiItemName } from '../../ui/ground-labels.ts'
|
||||||
|
import { HudManager } from '../../ui/hud-manager.ts'
|
||||||
|
import type { Minimap } from '../../ui/minimap.ts'
|
||||||
|
import { handleActSceneChatCommand } from '../backend/chat-commands.ts'
|
||||||
|
import { subTileToScene } from '../backend/scene-links-nav.ts'
|
||||||
|
import {
|
||||||
|
HERO_CLASSES,
|
||||||
|
ROOM_ACTIVATION_RADIUS,
|
||||||
|
ROOM_DEACTIVATION_RADIUS,
|
||||||
|
buildStreamingRooms,
|
||||||
|
isTownLevel,
|
||||||
|
normalizeHeroToken,
|
||||||
|
parseDensityMultiplier,
|
||||||
|
parseEliteMultiplier,
|
||||||
|
rebudgetMonsterPacks,
|
||||||
|
type DensityMultiplier,
|
||||||
|
type EliteMultiplier,
|
||||||
|
} from '../backend/scene-population.ts'
|
||||||
|
import {
|
||||||
|
DEFAULT_BASES,
|
||||||
|
loadCharacterArt,
|
||||||
|
loadMonsterArtMap,
|
||||||
|
type LoadedCharacter,
|
||||||
|
} from './entity-art-loader.ts'
|
||||||
|
import {
|
||||||
|
LIGHTING_PRESETS,
|
||||||
|
normalizeLightingPreset,
|
||||||
|
type LightingPreset,
|
||||||
|
} from './lighting-presets.ts'
|
||||||
|
import {
|
||||||
|
ACT_COUNT,
|
||||||
|
fetchJson,
|
||||||
|
param,
|
||||||
|
type MapRuntime,
|
||||||
|
type PackIndex,
|
||||||
|
} from './pack-loader.ts'
|
||||||
|
import {
|
||||||
|
recordMonsterArtLoad,
|
||||||
|
reportMonsterPlan,
|
||||||
|
state,
|
||||||
|
} from './scene-state.ts'
|
||||||
|
|
||||||
|
export { handleActSceneChatCommand }
|
||||||
|
|
||||||
|
export function updateSelectorWarningStyle(select: HTMLSelectElement | null, defaultValue: string = '1'): void {
|
||||||
|
if (!select) return
|
||||||
|
const isNonDefault = select.value !== defaultValue
|
||||||
|
select.classList.toggle('non-default', isNonDefault)
|
||||||
|
if (isNonDefault) {
|
||||||
|
select.style.border = '1px solid #e74c3c'
|
||||||
|
} else {
|
||||||
|
select.style.border = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateToolbarUrl(
|
||||||
|
currentUrlOrSearch: string | URL,
|
||||||
|
options: { density?: number | string | undefined; elite?: number | string | undefined },
|
||||||
|
): string {
|
||||||
|
const isUrl = typeof currentUrlOrSearch === 'object' || currentUrlOrSearch.startsWith('http://') || currentUrlOrSearch.startsWith('https://')
|
||||||
|
const url = isUrl
|
||||||
|
? new URL(typeof currentUrlOrSearch === 'string' ? currentUrlOrSearch : currentUrlOrSearch.href)
|
||||||
|
: new URL(`http://localhost${currentUrlOrSearch.startsWith('?') ? currentUrlOrSearch : '?' + currentUrlOrSearch}`)
|
||||||
|
|
||||||
|
if (options.density !== undefined) {
|
||||||
|
const density = parseDensityMultiplier(options.density)
|
||||||
|
if (density === 1) {
|
||||||
|
url.searchParams.delete('density')
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('density', String(density))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.elite !== undefined) {
|
||||||
|
const elite = parseEliteMultiplier(options.elite)
|
||||||
|
if (elite === 1) {
|
||||||
|
url.searchParams.delete('elite')
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('elite', String(elite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
return url.search
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MountChatAndToolbarUiOptions {
|
||||||
|
readonly engine: GameEngine
|
||||||
|
readonly status: HTMLElement
|
||||||
|
readonly executeChatCommand: (rawCommand: string) => boolean
|
||||||
|
readonly densitySelect: HTMLSelectElement | null
|
||||||
|
readonly eliteSelect: HTMLSelectElement | null
|
||||||
|
readonly densityMultiplier: number
|
||||||
|
readonly eliteMultiplier: number | 'all'
|
||||||
|
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the in-game chat/console input overlay (`#d2-chat-input`) and wire
|
||||||
|
* toolbar density/elite selectors.
|
||||||
|
*/
|
||||||
|
export function mountChatAndToolbarUi(options: MountChatAndToolbarUiOptions): void {
|
||||||
|
const {
|
||||||
|
executeChatCommand,
|
||||||
|
densitySelect,
|
||||||
|
eliteSelect,
|
||||||
|
densityMultiplier,
|
||||||
|
eliteMultiplier,
|
||||||
|
reloadMonsters,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// In-game chat/console input overlay supporting "/players <1-8>"
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
let chatInput = document.querySelector<HTMLInputElement>('#d2-chat-input')
|
||||||
|
if (!chatInput) {
|
||||||
|
chatInput = document.createElement('input')
|
||||||
|
chatInput.id = 'd2-chat-input'
|
||||||
|
chatInput.type = 'text'
|
||||||
|
chatInput.placeholder = getTblLang() === 'ENG'
|
||||||
|
? 'Type /players 1-8 or message, press Enter to send…'
|
||||||
|
: '输入 /players 1-8 或消息,按 Enter 发送…'
|
||||||
|
chatInput.className = 'overlay'
|
||||||
|
chatInput.style.position = 'fixed'
|
||||||
|
chatInput.style.left = '12px'
|
||||||
|
chatInput.style.bottom = '56px'
|
||||||
|
chatInput.style.width = '320px'
|
||||||
|
chatInput.style.padding = '4px 8px'
|
||||||
|
chatInput.style.background = 'rgba(11, 9, 6, 0.9)'
|
||||||
|
chatInput.style.color = '#e8dcc8'
|
||||||
|
chatInput.style.border = '1px solid #c8a15a'
|
||||||
|
chatInput.style.borderRadius = '4px'
|
||||||
|
chatInput.style.font = 'inherit'
|
||||||
|
chatInput.style.fontSize = '12px'
|
||||||
|
chatInput.style.zIndex = '10'
|
||||||
|
chatInput.style.display = 'none'
|
||||||
|
document.body.appendChild(chatInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChat = () => {
|
||||||
|
if (!chatInput) return
|
||||||
|
chatInput.placeholder = getTblLang() === 'ENG'
|
||||||
|
? 'Type /players 1-8 or message, press Enter to send…'
|
||||||
|
: '输入 /players 1-8 或消息,按 Enter 发送…'
|
||||||
|
chatInput.style.display = 'block'
|
||||||
|
chatInput.value = ''
|
||||||
|
chatInput.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeChat = () => {
|
||||||
|
if (!chatInput) return
|
||||||
|
chatInput.value = ''
|
||||||
|
chatInput.blur()
|
||||||
|
chatInput.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
chatInput.addEventListener('keydown', (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const text = chatInput!.value
|
||||||
|
closeChat()
|
||||||
|
if (text) {
|
||||||
|
executeChatCommand(text)
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
closeChat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const active = document.activeElement
|
||||||
|
if (
|
||||||
|
active instanceof HTMLInputElement ||
|
||||||
|
active instanceof HTMLSelectElement ||
|
||||||
|
active instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
openChat()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (densitySelect !== null) {
|
||||||
|
densitySelect.disabled = false
|
||||||
|
densitySelect.value = String(densityMultiplier)
|
||||||
|
updateSelectorWarningStyle(densitySelect, '1')
|
||||||
|
densitySelect.onchange = () => {
|
||||||
|
void reloadMonsters({ density: densitySelect.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (eliteSelect !== null) {
|
||||||
|
eliteSelect.disabled = false
|
||||||
|
eliteSelect.value = String(eliteMultiplier)
|
||||||
|
updateSelectorWarningStyle(eliteSelect, '1')
|
||||||
|
eliteSelect.onchange = () => {
|
||||||
|
void reloadMonsters({ elite: eliteSelect.value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroLightingAndMonsterControlsContext {
|
||||||
|
readonly renderer: SpriteRenderer
|
||||||
|
readonly dropTables: DropTables
|
||||||
|
readonly playerAnimator: ActorAnimator
|
||||||
|
readonly status: HTMLElement
|
||||||
|
readonly heroSelect: HTMLSelectElement | null
|
||||||
|
readonly lightingSelect: HTMLSelectElement | null
|
||||||
|
readonly densitySelect: HTMLSelectElement | null
|
||||||
|
readonly eliteSelect: HTMLSelectElement | null
|
||||||
|
readonly getRuntime: () => MapRuntime
|
||||||
|
readonly getEngine: () => GameEngine
|
||||||
|
readonly getHudManager: () => HudManager | null
|
||||||
|
readonly getCurrentHeroToken: () => string
|
||||||
|
readonly setCurrentHeroToken: (token: string) => void
|
||||||
|
readonly getCharacter: () => LoadedCharacter | null
|
||||||
|
readonly setCharacter: (char: LoadedCharacter | null) => void
|
||||||
|
readonly getActivePalette: () => Palette | null
|
||||||
|
readonly getPackEntityBase: () => string | undefined
|
||||||
|
readonly getPackEntityAct: () => number | undefined
|
||||||
|
readonly getCurrentLightingPreset: () => LightingPreset
|
||||||
|
readonly setLightingState: (preset: LightingPreset, enabled: boolean) => void
|
||||||
|
readonly getMultipliers: () => { densityMultiplier: DensityMultiplier; eliteMultiplier: EliteMultiplier }
|
||||||
|
readonly setMultipliers: (density: DensityMultiplier, elite: EliteMultiplier) => void
|
||||||
|
readonly setMonsterArtMap: (map: Map<string, LoadedMonsterArt>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroLightingAndMonsterControls {
|
||||||
|
readonly syncSorceressAppearance: () => Promise<boolean>
|
||||||
|
readonly switchLighting: (preset: string) => LightingPreset
|
||||||
|
readonly switchHero: (newHeroToken: string) => Promise<boolean>
|
||||||
|
readonly reloadMonsters: (options?: { density?: number | string; elite?: number | string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHeroLightingAndMonsterControls(
|
||||||
|
ctx: HeroLightingAndMonsterControlsContext,
|
||||||
|
): HeroLightingAndMonsterControls {
|
||||||
|
const soCompositor = new SorceressLayerCompositor(ctx.getPackEntityBase() ?? 'samples/d2-packs')
|
||||||
|
let activeAppearanceKey = ''
|
||||||
|
let appearanceSeq = 0
|
||||||
|
|
||||||
|
const syncSorceressAppearance = async (): Promise<boolean> => {
|
||||||
|
const hudInst = ctx.getHudManager() ?? (
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __d2webHudInstance?: HudManager }).__d2webHudInstance ?? null
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
const currentHeroToken = ctx.getCurrentHeroToken()
|
||||||
|
if (currentHeroToken !== 'so' || hudInst === null) {
|
||||||
|
activeAppearanceKey = ''
|
||||||
|
state.characterAppearanceKey = undefined
|
||||||
|
state.characterAppearance = undefined
|
||||||
|
if (hudInst !== null) {
|
||||||
|
const wCode = hudInst.inventory.equipped.weapon1?.code
|
||||||
|
state.weaponClass = wCode === '7wa' ? '2hs' : (wCode ? '1hs' : 'hth')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const spec = resolveSorceressAppearance(hudInst.inventory.equipped, ctx.dropTables)
|
||||||
|
const key = appearanceKey(spec)
|
||||||
|
state.weaponClass = spec.wclass
|
||||||
|
state.characterAppearance = spec
|
||||||
|
|
||||||
|
if (key === activeAppearanceKey) return false
|
||||||
|
activeAppearanceKey = key
|
||||||
|
state.characterAppearanceKey = key
|
||||||
|
const seq = ++appearanceSeq
|
||||||
|
|
||||||
|
try {
|
||||||
|
const composited = await soCompositor.composite(spec)
|
||||||
|
if (seq !== appearanceSeq || ctx.getCurrentHeroToken() !== 'so') return false
|
||||||
|
const newHandle = ctx.renderer.addIndexedAtlas(composited.r8, composited.width, composited.height)
|
||||||
|
const groups: AtlasFrame[][] = composited.groups.map(group =>
|
||||||
|
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
anchorX,
|
||||||
|
anchorY,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const prevChar = ctx.getCharacter()
|
||||||
|
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
|
||||||
|
ctx.setCharacter({
|
||||||
|
handle: newHandle,
|
||||||
|
groups,
|
||||||
|
directions: composited.directions,
|
||||||
|
walk: composited.walk,
|
||||||
|
stand: composited.stand,
|
||||||
|
members: composited.members,
|
||||||
|
clips: composited.clips,
|
||||||
|
})
|
||||||
|
state.character = true
|
||||||
|
state.characterMembers = composited.members
|
||||||
|
state.characterAppearanceKey = key
|
||||||
|
state.characterAppearance = spec
|
||||||
|
if (ctx.playerAnimator.mode !== null) {
|
||||||
|
const nextClip = composited.clips[ctx.playerAnimator.mode]
|
||||||
|
if (nextClip !== undefined) ctx.playerAnimator.syncClip(nextClip)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Sorceress appearance composite failed:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchLighting = (preset: string): LightingPreset => {
|
||||||
|
const nextPreset = normalizeLightingPreset(preset)
|
||||||
|
const cfg = LIGHTING_PRESETS[nextPreset]
|
||||||
|
ctx.setLightingState(nextPreset, cfg.enabled)
|
||||||
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
const modeLabel = isEn ? cfg.labelEn : cfg.labelZh
|
||||||
|
state.lightingPreset = nextPreset
|
||||||
|
state.lightingMode = modeLabel
|
||||||
|
if (ctx.lightingSelect !== null && ctx.lightingSelect.value !== nextPreset) {
|
||||||
|
ctx.lightingSelect.value = nextPreset
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = new URL(location.href)
|
||||||
|
if (nextPreset === 'auto') url.searchParams.delete('lighting')
|
||||||
|
else url.searchParams.set('lighting', nextPreset)
|
||||||
|
history.replaceState(null, '', url.toString())
|
||||||
|
} catch {
|
||||||
|
// ignore in non-browser context
|
||||||
|
}
|
||||||
|
const msgPrefix = isEn ? 'Scene lighting switched to: ' : '已切换场景光照为:'
|
||||||
|
ctx.status.textContent = `${msgPrefix}${modeLabel}`
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ctx.status.textContent.startsWith(msgPrefix)) ctx.status.textContent = ''
|
||||||
|
}, 2500)
|
||||||
|
return nextPreset
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||||
|
const nextToken = normalizeHeroToken(newHeroToken)
|
||||||
|
if (nextToken === ctx.getCurrentHeroToken() && ctx.getCharacter() !== null) return true
|
||||||
|
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
|
||||||
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
const heroDispName = isEn ? nextInfo.nameEn : nextInfo.nameZh
|
||||||
|
ctx.status.textContent = isEn ? `Switching hero to ${heroDispName} …` : `正在切换角色至 ${heroDispName} …`
|
||||||
|
const runtime = ctx.getRuntime()
|
||||||
|
const nextChar = await loadCharacterArt(
|
||||||
|
runtime.charBases,
|
||||||
|
ctx.getActivePalette(),
|
||||||
|
ctx.renderer,
|
||||||
|
ctx.getPackEntityBase(),
|
||||||
|
ctx.getPackEntityAct(),
|
||||||
|
nextToken,
|
||||||
|
)
|
||||||
|
if (nextChar !== null) {
|
||||||
|
const prevChar = ctx.getCharacter()
|
||||||
|
if (prevChar?.handle) ctx.renderer.deleteAtlas(prevChar.handle)
|
||||||
|
ctx.setCharacter(nextChar)
|
||||||
|
ctx.setCurrentHeroToken(nextToken)
|
||||||
|
state.heroToken = nextToken
|
||||||
|
state.heroName = nextInfo.nameEn
|
||||||
|
state.character = true
|
||||||
|
state.characterMembers = nextChar.members
|
||||||
|
const hudManager = ctx.getHudManager()
|
||||||
|
if (hudManager !== null) hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
||||||
|
activeAppearanceKey = ''
|
||||||
|
if (nextToken === 'so') await syncSorceressAppearance()
|
||||||
|
else {
|
||||||
|
state.characterAppearanceKey = undefined
|
||||||
|
state.characterAppearance = undefined
|
||||||
|
}
|
||||||
|
if (ctx.heroSelect !== null && ctx.heroSelect.value !== nextToken) ctx.heroSelect.value = nextToken
|
||||||
|
try {
|
||||||
|
const url = new URL(location.href)
|
||||||
|
url.searchParams.set('hero', nextToken)
|
||||||
|
history.replaceState(null, '', url.toString())
|
||||||
|
} catch {
|
||||||
|
// ignore in non-browser context
|
||||||
|
}
|
||||||
|
const curChar = ctx.getCharacter()
|
||||||
|
if (curChar?.clips?.nu) ctx.playerAnimator.play('nu', curChar.clips.nu, { restart: true })
|
||||||
|
const switchedPrefix = isEn ? 'Switched hero to ' : '已切换角色为 '
|
||||||
|
ctx.status.textContent = isEn
|
||||||
|
? `${switchedPrefix}${heroDispName} (${String(curChar?.members ?? nextChar.members)} layers).`
|
||||||
|
: `${switchedPrefix}${heroDispName}(${String(curChar?.members ?? nextChar.members)} 层素材)。`
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ctx.status.textContent.startsWith(switchedPrefix)) ctx.status.textContent = ''
|
||||||
|
}, 3000)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
ctx.status.textContent = isEn ? `Failed to switch hero to ${heroDispName}.` : `切换角色 ${heroDispName} 失败。`
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloadMonsters = async (options?: { density?: number | string; elite?: number | string }): Promise<void> => {
|
||||||
|
let { densityMultiplier, eliteMultiplier } = ctx.getMultipliers()
|
||||||
|
if (options?.density !== undefined) densityMultiplier = parseDensityMultiplier(options.density)
|
||||||
|
if (options?.elite !== undefined) eliteMultiplier = parseEliteMultiplier(options.elite)
|
||||||
|
ctx.setMultipliers(densityMultiplier, eliteMultiplier)
|
||||||
|
state.densityMultiplier = densityMultiplier
|
||||||
|
state.eliteMultiplier = eliteMultiplier
|
||||||
|
|
||||||
|
if (ctx.densitySelect !== null) {
|
||||||
|
ctx.densitySelect.value = String(densityMultiplier)
|
||||||
|
updateSelectorWarningStyle(ctx.densitySelect, '1')
|
||||||
|
}
|
||||||
|
if (ctx.eliteSelect !== null) {
|
||||||
|
ctx.eliteSelect.value = String(eliteMultiplier)
|
||||||
|
updateSelectorWarningStyle(ctx.eliteSelect, '1')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof location !== 'undefined' && typeof history !== 'undefined') {
|
||||||
|
const nextSearch = updateToolbarUrl(location.search, { density: densityMultiplier, elite: eliteMultiplier })
|
||||||
|
const newUrl = `${location.pathname}${nextSearch}${location.hash}`
|
||||||
|
history.replaceState(null, '', newUrl)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore in non-browser context
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = ctx.getRuntime()
|
||||||
|
const engine = ctx.getEngine()
|
||||||
|
if (isTownLevel(runtime.levelId)) {
|
||||||
|
runtime.monsterPacks = []
|
||||||
|
engine.world.monsters.length = 0
|
||||||
|
engine.streamingManager = undefined
|
||||||
|
reportMonsterPlan(runtime)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePacks = runtime.baseMonsterPacks ?? runtime.monsterPacks
|
||||||
|
runtime.baseMonsterPacks = basePacks
|
||||||
|
runtime.monsterPacks = rebudgetMonsterPacks(basePacks, {
|
||||||
|
densityMultiplier,
|
||||||
|
eliteMultiplier,
|
||||||
|
levelId: runtime.levelId,
|
||||||
|
seed: WORLD_VARIANT_SEED ^ runtime.levelId,
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.world.monsters.length = 0
|
||||||
|
|
||||||
|
const player = engine.world.player
|
||||||
|
const currentSafeZones: SafeZone[] = [{ x: player.x, y: player.y, radius: 180 }]
|
||||||
|
for (const wp of runtime.waypoints) {
|
||||||
|
const pos = subTileToScene(wp.x, wp.y, runtime.grid)
|
||||||
|
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
for (const ent of runtime.entrances) {
|
||||||
|
const pos = subTileToScene(ent.x, ent.y, runtime.grid)
|
||||||
|
currentSafeZones.push({ x: pos.x, y: pos.y, radius: 160 })
|
||||||
|
}
|
||||||
|
for (const s of runtime.stashes ?? []) {
|
||||||
|
currentSafeZones.push({ x: s.x, y: s.y, radius: 160 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentResolvedPacks: MonsterPack[] = runtime.monsterPacks.map(pack => {
|
||||||
|
if (pack.landmarkTile !== undefined && pack.fixedPosition === undefined) {
|
||||||
|
return {
|
||||||
|
...pack,
|
||||||
|
fixedPosition: cellCentre(runtime.grid, pack.landmarkTile.tileX, pack.landmarkTile.tileY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pack
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextRooms = buildStreamingRooms(runtime, currentResolvedPacks, currentSafeZones)
|
||||||
|
if (nextRooms.length > 0) {
|
||||||
|
engine.streamingManager = new MonsterStreamingManager(
|
||||||
|
engine.world,
|
||||||
|
engine.terrain,
|
||||||
|
nextRooms,
|
||||||
|
{
|
||||||
|
activationRadius: ROOM_ACTIVATION_RADIUS,
|
||||||
|
deactivationRadius: ROOM_DEACTIVATION_RADIUS,
|
||||||
|
safeZones: currentSafeZones,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
engine.streamingManager = undefined
|
||||||
|
if (currentResolvedPacks.length > 0) {
|
||||||
|
spawnMonsterPacks(
|
||||||
|
engine.world,
|
||||||
|
currentResolvedPacks,
|
||||||
|
{ x: player.x, y: player.y },
|
||||||
|
Math.max(runtime.widthPx, runtime.heightPx) / 4,
|
||||||
|
engine.terrain,
|
||||||
|
currentSafeZones,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reportMonsterPlan(runtime)
|
||||||
|
|
||||||
|
if (runtime.palette !== null) {
|
||||||
|
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
||||||
|
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
|
||||||
|
if (activeMonsterIds.length > 0) {
|
||||||
|
const loaded = await loadMonsterArtMap(
|
||||||
|
DEFAULT_BASES,
|
||||||
|
activeMonsterIds,
|
||||||
|
runtime.palette,
|
||||||
|
ctx.renderer,
|
||||||
|
ctx.getPackEntityBase(),
|
||||||
|
ctx.getPackEntityAct(),
|
||||||
|
)
|
||||||
|
ctx.setMonsterArtMap(loaded.map)
|
||||||
|
recordMonsterArtLoad(loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
syncSorceressAppearance,
|
||||||
|
switchLighting,
|
||||||
|
switchHero,
|
||||||
|
reloadMonsters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createActSceneHudManager(options: {
|
||||||
|
readonly runtime: MapRuntime
|
||||||
|
readonly engine: GameEngine
|
||||||
|
readonly minimap: Minimap
|
||||||
|
readonly status: HTMLElement
|
||||||
|
readonly currentHeroToken: string
|
||||||
|
readonly onWaypointTeleport: (targetAct: number, slug: string, levelId?: number) => void
|
||||||
|
readonly onCastTownPortal: () => void
|
||||||
|
}): Promise<HudManager | null> {
|
||||||
|
const {
|
||||||
|
runtime,
|
||||||
|
engine,
|
||||||
|
minimap,
|
||||||
|
status,
|
||||||
|
currentHeroToken,
|
||||||
|
onWaypointTeleport,
|
||||||
|
onCastTownPortal,
|
||||||
|
} = options
|
||||||
|
const hudCanvas = document.querySelector<HTMLCanvasElement>('#d2-hud-canvas')
|
||||||
|
const hudManager = hudCanvas !== null
|
||||||
|
? new HudManager(hudCanvas, {
|
||||||
|
onToggleAutomap: () => {
|
||||||
|
minimap.toggleVisibility()
|
||||||
|
state.minimapVisible = minimap.visible
|
||||||
|
},
|
||||||
|
onToggleAutomapMode: () => {
|
||||||
|
minimap.toggleMinimapMode()
|
||||||
|
state.minimapVisible = minimap.visible
|
||||||
|
state.automapMode = minimap.mode
|
||||||
|
state.automapDivisor = minimap.divisor
|
||||||
|
},
|
||||||
|
onCycleAutomapFade: () => { minimap.cycleFade() },
|
||||||
|
onToggleAutomapNames: () => { minimap.toggleNames() },
|
||||||
|
onResetAutomapPan: () => { minimap.resetPan() },
|
||||||
|
onPanAutomap: (dx, dy) => minimap.pan(dx, dy),
|
||||||
|
onRenderAutomapOverlay: (ctx, font, logicalWidth, logicalHeight) => {
|
||||||
|
minimap.drawHudOverlay(ctx, font, logicalWidth, logicalHeight)
|
||||||
|
},
|
||||||
|
onWaypointTeleport: (targetAct, slug, levelId) => {
|
||||||
|
onWaypointTeleport(targetAct, slug, levelId)
|
||||||
|
},
|
||||||
|
onCastTownPortal: () => onCastTownPortal(),
|
||||||
|
onDropItemToGround: (item) => {
|
||||||
|
const player = engine.world.player
|
||||||
|
const groundItem = engine.dropItem(item, player.x, player.y)
|
||||||
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
status.textContent = isEn
|
||||||
|
? `Dropped "${groundItem.name}" on ground`
|
||||||
|
: `已将「${cleanChiItemName(groundItem.nameZh)}」丢弃在地面`
|
||||||
|
},
|
||||||
|
onDropGoldToGround: (amount) => {
|
||||||
|
const player = engine.world.player
|
||||||
|
const groundGold = engine.dropGold(amount, player.x, player.y)
|
||||||
|
if (groundGold) {
|
||||||
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
status.textContent = isEn
|
||||||
|
? `Dropped ${amount.toLocaleString()} Gold on ground`
|
||||||
|
: `已将 ${amount.toLocaleString()} 金币丢弃在地面`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (hudManager !== null) {
|
||||||
|
hudManager.worldPanels.setCurrentLevel(runtime.levelId ?? null)
|
||||||
|
hudManager.worldPanels.setCurrentWaypoint(runtime.waypoints[0]?.waypointId ?? null, runtime.levelId ?? null)
|
||||||
|
hudManager.worldPanels.selectedActTab = runtime.act
|
||||||
|
try {
|
||||||
|
await hudManager.init('/ui')
|
||||||
|
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
|
||||||
|
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
|
||||||
|
const customCharName = param('charName', '').trim()
|
||||||
|
if (customCharName) hudManager.charSheet.attrs.name = customCharName
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize HudManager:', err)
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.__d2webHud) {
|
||||||
|
(window.__d2webHud as any).proceduralFallbackCount = hudManager.globes.proceduralFallbackCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hudManager
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireBootToolbarSelectors(options: {
|
||||||
|
readonly isEn: boolean
|
||||||
|
readonly heroSelect: HTMLSelectElement | null
|
||||||
|
readonly actSelect: HTMLSelectElement
|
||||||
|
readonly sceneSelect: HTMLSelectElement
|
||||||
|
readonly variantSelect: HTMLSelectElement
|
||||||
|
readonly lightingSelect: HTMLSelectElement | null
|
||||||
|
readonly langSwitch: HTMLAnchorElement | null
|
||||||
|
}): number {
|
||||||
|
const {
|
||||||
|
isEn,
|
||||||
|
heroSelect,
|
||||||
|
actSelect,
|
||||||
|
sceneSelect,
|
||||||
|
variantSelect,
|
||||||
|
lightingSelect,
|
||||||
|
langSwitch,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (langSwitch !== null && typeof location !== 'undefined') {
|
||||||
|
const targetLang: TblLang = isEn ? 'CHI' : 'ENG'
|
||||||
|
langSwitch.textContent = isEn ? 'Lang: ZH' : '语言: EN'
|
||||||
|
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
|
||||||
|
langSwitch.addEventListener('click', () => {
|
||||||
|
langSwitch.href = buildLangSwitchHref(targetLang, location.pathname, location.search)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lightingSelect !== null) {
|
||||||
|
lightingSelect.disabled = false
|
||||||
|
lightingSelect.value = normalizeLightingPreset(param('lighting', 'auto'))
|
||||||
|
lightingSelect.onchange = () => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (lightingSelect.value === 'auto') params.delete('lighting')
|
||||||
|
else params.set('lighting', lightingSelect.value)
|
||||||
|
location.search = params.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (heroSelect !== null) {
|
||||||
|
heroSelect.disabled = false
|
||||||
|
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
|
||||||
|
heroSelect.onchange = () => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set('hero', heroSelect.value)
|
||||||
|
location.search = params.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const densitySelect = document.querySelector<HTMLSelectElement>('#density')
|
||||||
|
const eliteSelect = document.querySelector<HTMLSelectElement>('#elite')
|
||||||
|
if (densitySelect !== null) {
|
||||||
|
densitySelect.disabled = false
|
||||||
|
densitySelect.value = String(parseDensityMultiplier(param('density', '1')))
|
||||||
|
updateSelectorWarningStyle(densitySelect, '1')
|
||||||
|
}
|
||||||
|
if (eliteSelect !== null) {
|
||||||
|
eliteSelect.disabled = false
|
||||||
|
eliteSelect.value = String(parseEliteMultiplier(param('elite', '1')))
|
||||||
|
updateSelectorWarningStyle(eliteSelect, '1')
|
||||||
|
}
|
||||||
|
actSelect.disabled = false
|
||||||
|
sceneSelect.disabled = true
|
||||||
|
variantSelect.disabled = true
|
||||||
|
|
||||||
|
if (typeof Image !== 'undefined') {
|
||||||
|
const criticalImages = [
|
||||||
|
'/ui/ctrlpnl-800.png',
|
||||||
|
'/ui/globe-life.png',
|
||||||
|
'/ui/globe-mana.png',
|
||||||
|
'/ui/globe-poison.png',
|
||||||
|
'/ui/overlap-left.png',
|
||||||
|
'/ui/overlap-right.png',
|
||||||
|
'/ui/minipanel.png',
|
||||||
|
'/ui/minipanel-btns.png',
|
||||||
|
'/ui/runbutton.png',
|
||||||
|
'/ui/menubutton.png',
|
||||||
|
'/ui/border-left.png',
|
||||||
|
'/ui/border-right.png',
|
||||||
|
]
|
||||||
|
for (const src of criticalImages) {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = src
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const act = Math.min(ACT_COUNT, Math.max(1, Number(param('act', '1'))))
|
||||||
|
state.act = act
|
||||||
|
actSelect.value = String(act)
|
||||||
|
actSelect.addEventListener('change', () => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set('act', actSelect.value)
|
||||||
|
params.delete('level')
|
||||||
|
location.search = params.toString()
|
||||||
|
})
|
||||||
|
return act
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the level picker from a pack index and wire it to navigation.
|
||||||
|
*
|
||||||
|
* Every baked map is one entry, grouped by act. Picking one reloads the page with
|
||||||
|
* `?act=` and `?level=`, which is the same query the loader already understands,
|
||||||
|
* so the picker needs no second loading path.
|
||||||
|
*
|
||||||
|
* @param packBase - directory holding `index.json`.
|
||||||
|
* @param current - the act and the entry label currently displayed (`runtime.quadrant`),
|
||||||
|
* so the list marks exactly the map that is on screen.
|
||||||
|
*/
|
||||||
|
export async function populatePackSelectors(
|
||||||
|
packBase: string,
|
||||||
|
current: { act: number; label: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const actSelect = document.querySelector<HTMLSelectElement>('#act')
|
||||||
|
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')
|
||||||
|
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')
|
||||||
|
if (actSelect === null || sceneSelect === null || variantSelect === null) return
|
||||||
|
let index: PackIndex
|
||||||
|
try {
|
||||||
|
index = await fetchJson<PackIndex>(`${packBase}/index.json`)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEn = getTblLang() === 'ENG'
|
||||||
|
|
||||||
|
/** A scene type: one `Levels.txt` level, with every baked DS1 variant under it. */
|
||||||
|
interface Scene {
|
||||||
|
readonly slug: string
|
||||||
|
readonly name: string
|
||||||
|
readonly variants: readonly PackIndex['levels'][number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenesByAct = new Map<number, Scene[]>()
|
||||||
|
for (const entry of index.levels) {
|
||||||
|
let scenes = scenesByAct.get(entry.act)
|
||||||
|
if (scenes === undefined) { scenes = []; scenesByAct.set(entry.act, scenes) }
|
||||||
|
let scene = scenes.find(candidate => candidate.slug === entry.slug)
|
||||||
|
if (scene === undefined) {
|
||||||
|
scene = {
|
||||||
|
slug: entry.slug,
|
||||||
|
name: sceneDisplayName(entry.slug, entry.levelName, isEn ? 'ENG' : 'CHI'),
|
||||||
|
variants: [],
|
||||||
|
}
|
||||||
|
scenes.push(scene)
|
||||||
|
}
|
||||||
|
;(scene.variants as PackIndex['levels'][number][]).push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The suffix after the level slug, which is the DS1's own name fragment. */
|
||||||
|
const suffixOf = (entry: PackIndex['levels'][number]): string =>
|
||||||
|
entry.label.startsWith(`${entry.slug}-`) ? entry.label.slice(entry.slug.length + 1) : entry.label
|
||||||
|
|
||||||
|
const optionFor = (value: string, text: string, selected: boolean): HTMLOptionElement => {
|
||||||
|
const option = document.createElement('option')
|
||||||
|
option.value = value
|
||||||
|
option.textContent = text
|
||||||
|
option.selected = selected
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
|
||||||
|
const acts = [...scenesByAct.keys()].sort((left, right) => left - right)
|
||||||
|
actSelect.replaceChildren(...acts.map(act => optionFor(
|
||||||
|
String(act),
|
||||||
|
isEn
|
||||||
|
? `${ACT_NAMES_EN[act] ?? `Act ${String(act)}`}`
|
||||||
|
: `${ACT_NAMES_ZH[act] ?? `第 ${String(act)} 章`}`,
|
||||||
|
act === current.act,
|
||||||
|
)))
|
||||||
|
actSelect.disabled = acts.length === 0
|
||||||
|
|
||||||
|
const scenes = scenesByAct.get(current.act) ?? []
|
||||||
|
const currentEntry = index.levels.find(entry => entry.act === current.act && entry.label === current.label)
|
||||||
|
const currentSceneSlug = currentEntry?.slug ?? scenes[0]?.slug ?? ''
|
||||||
|
sceneSelect.replaceChildren(...scenes.map(scene => optionFor(
|
||||||
|
scene.slug,
|
||||||
|
scene.name,
|
||||||
|
scene.slug === currentSceneSlug,
|
||||||
|
)))
|
||||||
|
sceneSelect.disabled = scenes.length === 0
|
||||||
|
|
||||||
|
const populateVariants = (slug: string, selectedLabel: string): void => {
|
||||||
|
const scene = scenes.find(candidate => candidate.slug === slug)
|
||||||
|
const variants = scene?.variants ?? []
|
||||||
|
// A single-variant scene still shows its own DS1 so the third selector is never
|
||||||
|
// an empty box; the option text repeats the scene name only when it is the only one.
|
||||||
|
variantSelect.replaceChildren(...variants.map(entry => {
|
||||||
|
const suffix = suffixOf(entry)
|
||||||
|
const label = variants.length > 1
|
||||||
|
? variantDisplayLabel(suffix, isEn ? 'ENG' : 'CHI')
|
||||||
|
: isEn
|
||||||
|
? `${scene?.name ?? entry.levelName} (${suffix})`
|
||||||
|
: `${scene?.name ?? entry.levelName}(${suffix})`
|
||||||
|
return optionFor(entry.label, label, entry.label === selectedLabel)
|
||||||
|
}))
|
||||||
|
variantSelect.disabled = variants.length === 0
|
||||||
|
}
|
||||||
|
populateVariants(currentSceneSlug, current.label)
|
||||||
|
variantSelect.dataset.scene = currentSceneSlug
|
||||||
|
|
||||||
|
const go = (params: URLSearchParams): void => { location.search = params.toString() }
|
||||||
|
const navigateToLabel = (label: string, act: number): void => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set('act', String(act))
|
||||||
|
params.set('level', label)
|
||||||
|
go(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
actSelect.onchange = () => {
|
||||||
|
const act = Number(actSelect.value)
|
||||||
|
const first = scenesByAct.get(act)?.[0]
|
||||||
|
// Keep the address bar and the map in sync: an act with no baked scene yet (there
|
||||||
|
// are none today) simply leaves the current map alone.
|
||||||
|
if (first === undefined) return
|
||||||
|
navigateToLabel(first.variants[0]?.label ?? '', act)
|
||||||
|
}
|
||||||
|
sceneSelect.onchange = () => {
|
||||||
|
const scene = scenes.find(candidate => candidate.slug === sceneSelect.value)
|
||||||
|
if (scene === undefined) return
|
||||||
|
populateVariants(scene.slug, '')
|
||||||
|
navigateToLabel(scene.variants[0]?.label ?? '', current.act)
|
||||||
|
}
|
||||||
|
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -33,9 +33,8 @@ import { decodePal } from '../formats/pal.ts'
|
||||||
import { decodePl2 } from '../formats/pl2.ts'
|
import { decodePl2 } from '../formats/pl2.ts'
|
||||||
import type { Palette } from '../formats/pal.ts'
|
import type { Palette } from '../formats/pal.ts'
|
||||||
import type { SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteSheet } from '../formats/sprite.ts'
|
||||||
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts'
|
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts'
|
||||||
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts'
|
import type { CombatOptions, CombatWorld } from '../game/combat.ts'
|
||||||
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
|
|
||||||
import { parseTable } from '../game/tables.ts'
|
import { parseTable } from '../game/tables.ts'
|
||||||
import { ActorAnimator } from '../game/animation.ts'
|
import { ActorAnimator } from '../game/animation.ts'
|
||||||
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
|
import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts'
|
||||||
|
|
@ -47,17 +46,15 @@ import { GameLoop } from '../sim/loop.ts'
|
||||||
import { KeyboardInput, directionOf } from '../sim/input.ts'
|
import { KeyboardInput, directionOf } from '../sim/input.ts'
|
||||||
import { NetplaySession } from '../net/netplay.ts'
|
import { NetplaySession } from '../net/netplay.ts'
|
||||||
import { socketTransport } from '../net/transport.ts'
|
import { socketTransport } from '../net/transport.ts'
|
||||||
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts'
|
import {
|
||||||
import { Inventory, type Item } from '../game/items.ts'
|
NET_SEED,
|
||||||
import { QuestLog } from '../game/quests.ts'
|
DEMO_MONSTERS,
|
||||||
import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts'
|
DEMO_EXPERIENCE,
|
||||||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
createNetSimulation,
|
||||||
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts'
|
type NetSources,
|
||||||
import { D2Rng } from '../game/d2-rng.ts'
|
} from './backend/net-simulation.ts'
|
||||||
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
|
|
||||||
import { worldToCell } from '../game/engine.ts'
|
|
||||||
|
|
||||||
import type { InputFrame, LockstepSimulation } from '../net/lockstep.ts'
|
export { NET_SEED, DEMO_MONSTERS, DEMO_EXPERIENCE, createNetSimulation, type NetSources }
|
||||||
|
|
||||||
/** Collision box at the character's feet. */
|
/** Collision box at the character's feet. */
|
||||||
const FEET_WIDTH = 20
|
const FEET_WIDTH = 20
|
||||||
|
|
@ -72,8 +69,6 @@ const INPUT_DELAY_TICKS = 4
|
||||||
const HASH_INTERVAL = 5
|
const HASH_INTERVAL = 5
|
||||||
/** Maximum map libraries to merge, matching the campaign scene. */
|
/** Maximum map libraries to merge, matching the campaign scene. */
|
||||||
const MAX_LIBRARIES = 4
|
const MAX_LIBRARIES = 4
|
||||||
/** World seed: fixed, because both peers must build the same world. */
|
|
||||||
const NET_SEED = 0x0d2c0de
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The page's state, exposed for the harness. Everything the automated checks look
|
* The page's state, exposed for the harness. Everything the automated checks look
|
||||||
|
|
@ -164,23 +159,6 @@ const state: NetState = {
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined') window.__d2webNet = state
|
if (typeof window !== 'undefined') window.__d2webNet = state
|
||||||
|
|
||||||
/** Raw members the scene needs. */
|
|
||||||
interface NetSources {
|
|
||||||
readonly ds1: Uint8Array
|
|
||||||
readonly libraries: readonly Uint8Array[]
|
|
||||||
readonly palette: Uint8Array
|
|
||||||
readonly paletteName: string
|
|
||||||
readonly actor: { readonly name: string; readonly sheet: SpriteSheet } | null
|
|
||||||
readonly monstats: Uint8Array | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The monster table used when the archive has none, so the page is never empty. */
|
|
||||||
const DEMO_MONSTERS: MonsterStats[] = monsterStatsFromTable(parseTable([
|
|
||||||
'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP',
|
|
||||||
'fallen\tFallen One\t16\t2\t24\t38\t420\t150\t12',
|
|
||||||
'zombie\tZombie\t30\t3\t32\t42\t360\t120\t20',
|
|
||||||
].join('\n')))
|
|
||||||
|
|
||||||
let running: GameLoop | null = null
|
let running: GameLoop | null = null
|
||||||
let input: KeyboardInput | null = null
|
let input: KeyboardInput | null = null
|
||||||
let activeRenderer: SpriteRenderer | null = null
|
let activeRenderer: SpriteRenderer | null = null
|
||||||
|
|
@ -514,9 +492,6 @@ function startScene(
|
||||||
+ '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。'
|
+ '少一人就等,因为「抢先开跑」会让先发出的输入掉进虚空,两边永久互等。'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback experience thresholds: the co-op sandbox is not about levelling. */
|
|
||||||
const DEMO_EXPERIENCE: readonly number[] = [0, 0, 250, 700, 1500, 3000]
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Digest the world.
|
* Digest the world.
|
||||||
*
|
*
|
||||||
|
|
@ -622,179 +597,3 @@ if (typeof window !== 'undefined') {
|
||||||
/** Whether the page is running inside a document (avoids running under Node). */
|
/** Whether the page is running inside a document (avoids running under Node). */
|
||||||
if (typeof document !== 'undefined') boot()
|
if (typeof document !== 'undefined') boot()
|
||||||
|
|
||||||
export function createNetSimulation(
|
|
||||||
world: CombatWorld,
|
|
||||||
options: CombatOptions,
|
|
||||||
terrain: CombatTerrain,
|
|
||||||
xpTable: readonly number[],
|
|
||||||
numPeers: number,
|
|
||||||
seed: number = NET_SEED,
|
|
||||||
dropTables: DropTables = getEmbeddedDropTables(),
|
|
||||||
difficulty: Difficulty = 'normal',
|
|
||||||
) {
|
|
||||||
const inventories = Array.from({ length: Math.max(numPeers, 1) }, () => new Inventory(10, 4))
|
|
||||||
const quests = new QuestLog([])
|
|
||||||
const ground: { x: number; y: number; item: Item }[] = []
|
|
||||||
|
|
||||||
world.pendingKills = world.pendingKills ?? []
|
|
||||||
for (let i = 0; i < world.monsters.length; i += 1) {
|
|
||||||
const monster = world.monsters[i]!
|
|
||||||
if (monster.dwInitSeed === undefined) {
|
|
||||||
monster.dwInitSeed = (((seed ^ Math.imul(monster.index + 1, 0x9e3779b9)) >>> 0) || 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapTerrain = 'widthPx' in terrain
|
|
||||||
? (terrain as any)
|
|
||||||
: { ...terrain, widthPx: 10000, heightPx: 10000 }
|
|
||||||
|
|
||||||
const simulation: LockstepSimulation = {
|
|
||||||
advance: (inputs: readonly InputFrame[]) => {
|
|
||||||
const frames: CombatInput[] = inputs.map(input => ({
|
|
||||||
movement: input.movement,
|
|
||||||
attack: input.attack,
|
|
||||||
}))
|
|
||||||
tickCombatMulti(world, frames, options, terrain, xpTable)
|
|
||||||
|
|
||||||
const killEvents = [
|
|
||||||
...(world.pendingKills ?? []),
|
|
||||||
...world.events.filter(e => e.kind === 'kill'),
|
|
||||||
]
|
|
||||||
if (world.pendingKills !== undefined) {
|
|
||||||
world.pendingKills.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const activePlayers = Math.max(1, Math.min(8, world.players.filter(p => p.alive).length || numPeers || 1))
|
|
||||||
|
|
||||||
for (const event of killEvents) {
|
|
||||||
if (event.kind !== 'kill') continue
|
|
||||||
const monster = event.monsterIndex !== undefined
|
|
||||||
? world.monsters[event.monsterIndex] ?? world.monsters.find(m => m.index === event.monsterIndex)
|
|
||||||
: world.monsters.find(m => m.stats.id === event.subjectId && Math.hypot(m.x - event.x, m.y - event.y) < 10)
|
|
||||||
if (monster?.dropRolled) continue
|
|
||||||
if (monster) {
|
|
||||||
monster.dropRolled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const subjectId = (event.subjectId ?? monster?.stats.id ?? '').trim()
|
|
||||||
const monsterRank = event.monsterRank ?? monster?.stats.rank ?? 'normal'
|
|
||||||
const isMinion = monsterRank === 'minion'
|
|
||||||
const monsterType = isMinion
|
|
||||||
? 3
|
|
||||||
: (event.monsterType ?? (monsterRank === 'boss' ? 4 : monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1))
|
|
||||||
|
|
||||||
const suDef = event.superUniqueId
|
|
||||||
? (dropTables.superUniques.get(event.superUniqueId) ??
|
|
||||||
(() => {
|
|
||||||
const target = event.superUniqueId!.trim().toLowerCase()
|
|
||||||
for (const [k, v] of dropTables.superUniques) {
|
|
||||||
if (k.toLowerCase() === target || v.id.toLowerCase() === target || v.nameKey?.toLowerCase() === target) {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})())
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
let tcName = ''
|
|
||||||
if (!isMinion && suDef) {
|
|
||||||
tcName = typeof suDef.getTreasureClass === 'function'
|
|
||||||
? suDef.getTreasureClass(difficulty)
|
|
||||||
: suDef.treasureClass
|
|
||||||
}
|
|
||||||
|
|
||||||
const monsterKind =
|
|
||||||
dropTables.monsterKinds.get(subjectId) ??
|
|
||||||
dropTables.monsterKinds.get(`${subjectId}1`) ??
|
|
||||||
(suDef ? dropTables.monsterKinds.get(suDef.monsterId) : undefined)
|
|
||||||
|
|
||||||
if (!tcName && monsterKind) {
|
|
||||||
tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType)
|
|
||||||
}
|
|
||||||
if (!tcName && !monsterKind && !suDef) {
|
|
||||||
tcName = 'Act 1 H2H A'
|
|
||||||
}
|
|
||||||
if (!tcName) continue
|
|
||||||
|
|
||||||
const kindLevel = typeof (monsterKind as any)?.level === 'number'
|
|
||||||
? (monsterKind as any).level
|
|
||||||
: (monsterKind as any)?.level?.[difficulty]
|
|
||||||
const nLevel = Math.max(1, event.monsterLevel ?? monster?.stats.level ?? kindLevel ?? 1)
|
|
||||||
const monsterIndex = monster?.index ?? event.monsterIndex ?? 0
|
|
||||||
const monsterSeed =
|
|
||||||
monster?.dwInitSeed ??
|
|
||||||
((((seed ^ Math.imul(monsterIndex + 1, 0x9e3779b9)) >>> 0) || 1))
|
|
||||||
|
|
||||||
const droppedItems = executeDropPipeline(dropTables, {
|
|
||||||
tcName,
|
|
||||||
nLevel,
|
|
||||||
monsterType,
|
|
||||||
difficulty,
|
|
||||||
gamePlayers: activePlayers,
|
|
||||||
partyPlayers: activePlayers,
|
|
||||||
playerMf: 0,
|
|
||||||
playerGf: 0,
|
|
||||||
isBoss: isMinion ? false : Boolean(monsterKind?.boss),
|
|
||||||
isNoRatio: Boolean(monsterKind?.noRatio),
|
|
||||||
monsterRng: new D2Rng(monsterSeed),
|
|
||||||
})
|
|
||||||
|
|
||||||
const burstOccupied: { x: number; y: number }[] = ground.map(g => ({ x: g.x, y: g.y }))
|
|
||||||
const alivePlayers = world.players.filter(p => p.alive)
|
|
||||||
const refPlayers = alivePlayers.length > 0 ? alivePlayers : world.players
|
|
||||||
let reachableFrom: { x: number; y: number } | undefined
|
|
||||||
if (refPlayers.length > 0) {
|
|
||||||
let bestPlayer = refPlayers[0]!
|
|
||||||
let bestDist = Math.hypot(bestPlayer.x - event.x, bestPlayer.y - event.y)
|
|
||||||
for (let pIdx = 1; pIdx < refPlayers.length; pIdx += 1) {
|
|
||||||
const p = refPlayers[pIdx]!
|
|
||||||
const d = Math.hypot(p.x - event.x, p.y - event.y)
|
|
||||||
if (d < bestDist) {
|
|
||||||
bestDist = d
|
|
||||||
bestPlayer = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reachableFrom = { x: bestPlayer.x, y: bestPlayer.y }
|
|
||||||
}
|
|
||||||
for (const drop of droppedItems) {
|
|
||||||
const dropPos = findIsometricDropPosition(
|
|
||||||
event.x,
|
|
||||||
event.y,
|
|
||||||
burstOccupied,
|
|
||||||
mapTerrain,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
reachableFrom,
|
|
||||||
)
|
|
||||||
burstOccupied.push(dropPos)
|
|
||||||
ground.push({ x: dropPos.x, y: dropPos.y, item: drop })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inputs.forEach((input, index) => {
|
|
||||||
if (input.pickup && ground.length > 0) {
|
|
||||||
const player = world.players[index]
|
|
||||||
if (player) {
|
|
||||||
let bestIndex = -1
|
|
||||||
let bestEdgeDist = 64
|
|
||||||
for (let gIdx = 0; gIdx < ground.length; gIdx += 1) {
|
|
||||||
const g = ground[gIdx]!
|
|
||||||
const edgeDist = getPickupEdgeDistance(player.x, player.y, g.x, g.y)
|
|
||||||
if (edgeDist <= bestEdgeDist) {
|
|
||||||
bestEdgeDist = edgeDist
|
|
||||||
bestIndex = gIdx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bestIndex !== -1 && inventories[index]!.add(ground[bestIndex]!.item) !== null) {
|
|
||||||
ground.splice(bestIndex, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hash: () => computeLockstepHash(world, inventories, ground, quests.all),
|
|
||||||
}
|
|
||||||
return { simulation, inventories, quests, ground }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue