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
10598
src/scene/act-scene.ts
10598
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 type { Palette } from '../formats/pal.ts'
|
||||
import type { SpriteSheet } from '../formats/sprite.ts'
|
||||
import { PLAYER_SPRITE_WIDTH } from '../formats/sprite.ts'
|
||||
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters, tickCombatMulti } from '../game/combat.ts'
|
||||
import type { CombatInput, CombatOptions, CombatTerrain, CombatWorld, MonsterStats } from '../game/combat.ts'
|
||||
import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts'
|
||||
import type { CombatOptions, CombatWorld } from '../game/combat.ts'
|
||||
import { parseTable } from '../game/tables.ts'
|
||||
import { ActorAnimator } from '../game/animation.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 { NetplaySession } from '../net/netplay.ts'
|
||||
import { socketTransport } from '../net/transport.ts'
|
||||
import { LockstepSession, computeLockstepHash } from '../net/lockstep.ts'
|
||||
import { Inventory, type Item } from '../game/items.ts'
|
||||
import { QuestLog } from '../game/quests.ts'
|
||||
import { executeDropPipeline, type DropTables } from '../game/drop-pipeline.ts'
|
||||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||
import { getMonsterTreasureClass, type Difficulty } from '../game/monsters.ts'
|
||||
import { D2Rng } from '../game/d2-rng.ts'
|
||||
import { findSafeDropPosition, findIsometricDropPosition, getPickupEdgeDistance } from '../game/ground-items.ts'
|
||||
import { worldToCell } from '../game/engine.ts'
|
||||
import {
|
||||
NET_SEED,
|
||||
DEMO_MONSTERS,
|
||||
DEMO_EXPERIENCE,
|
||||
createNetSimulation,
|
||||
type NetSources,
|
||||
} from './backend/net-simulation.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. */
|
||||
const FEET_WIDTH = 20
|
||||
|
|
@ -72,8 +69,6 @@ const INPUT_DELAY_TICKS = 4
|
|||
const HASH_INTERVAL = 5
|
||||
/** Maximum map libraries to merge, matching the campaign scene. */
|
||||
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
|
||||
|
|
@ -164,23 +159,6 @@ const state: NetState = {
|
|||
}
|
||||
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 input: KeyboardInput | 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.
|
||||
*
|
||||
|
|
@ -622,179 +597,3 @@ if (typeof window !== 'undefined') {
|
|||
/** Whether the page is running inside a document (avoids running under Node). */
|
||||
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