fix(client): restore canonical 1.13c spell visual effects (#550) #554

Merged
troytt merged 2 commits from fix/issue-550-spell-visual-effects into main 2026-10-02 09:28:11 +00:00
10 changed files with 2189 additions and 76 deletions

View File

@ -1,3 +1,4 @@
import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts'
import { dir64ToClipDirection } from '../../common/units/direction.ts'
import { PLR_MODES } from '../../common/units/plr-mode.ts'
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
@ -785,6 +786,9 @@ function drawSceneMissileDrawable(
ctx: SceneSourceRenderContext,
nowMs: number,
): void {
if (missile.celFile.toLowerCase() === 'sqr') {
return
}
const art =
ctx.missileArtMap?.get(missile.missileKey) ??
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
@ -803,18 +807,21 @@ function drawSceneMissileDrawable(
const group = art.frames[dir] ?? art.frames[0]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIdx =
(missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
frameCount
missile.loopAnim === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
: (missile.frame + computeAnimDataFrameIndex(nowMs, animSpeed * 16, frameCount, true)) %
frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const altitude = missile.altitude ?? 0
const drawX =
frame.anchorX !== undefined
? missile.worldX + frame.anchorX
: missile.worldX - frame.width / 2
const drawY =
frame.anchorY !== undefined
(frame.anchorY !== undefined
? missile.worldY + frame.anchorY
: missile.worldY - frame.height / 2
: missile.worldY - frame.height / 2) - altitude
renderer.draw(frame, drawX, drawY, {
atlas: art.handle,
blendMode: missile.blendMode,
@ -835,11 +842,20 @@ function drawSceneOverlayDrawable(
ctx: SceneSourceRenderContext,
nowMs: number,
): void {
const lowerToken = overlay.token.toLowerCase()
const celFile = CANONICAL_113C_OVERLAYS[lowerToken]?.filename
const lowerCel = celFile?.toLowerCase()
const art =
ctx.overlayArtMap?.get(overlay.token) ??
ctx.overlayArtMap?.get(overlay.token.toLowerCase())
ctx.overlayArtMap?.get(lowerToken) ??
(celFile ? ctx.overlayArtMap?.get(celFile) : undefined) ??
(lowerCel ? ctx.overlayArtMap?.get(lowerCel) : undefined) ??
ctx.missileArtMap?.get(overlay.token) ??
ctx.missileArtMap?.get(lowerToken) ??
(celFile ? ctx.missileArtMap?.get(celFile) : undefined) ??
(lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined)
if (art !== undefined) {
const animRate = art.meta.animRate
const animRate = 'animRate' in art.meta ? art.meta.animRate : art.meta.animSpeed
if (!(animRate > 0)) {
reportMissingArt(
'overlay-speed',
@ -851,8 +867,10 @@ function drawSceneOverlayDrawable(
const group = art.frames[0]
const frameCount = Math.max(1, group?.length ?? 1)
const frameIdx =
(overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
frameCount
overlay.loop === false
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
: (overlay.frame + computeAnimDataFrameIndex(nowMs, animRate * 16, frameCount, true)) %
frameCount
const frame = group?.[frameIdx] ?? group?.[0]
if (frame !== undefined) {
const drawX =

View File

@ -299,6 +299,40 @@ export class OnlineSession {
this.predictor = options.predictor ?? new LocalMovementPredictor()
this.interpolator = options.interpolator ?? new RemoteEntityInterpolator()
this.world.setCollisionChecker((subX, subY) => {
const predSnap = this.predictor.getSnapshot()
const px = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x
const py = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y
const activeView =
this.levelView ??
(this.mapService && this.world.areaId > 0 && (px !== 0 || py !== 0)
? this.mapService.findLevelViewAtWorldSubTile(px, py, [
this.world.areaId,
...this.world.getRevealedAreaIds(),
])
: null) ??
this.mapService?.getActiveLevelView() ??
this.automapView.getLevelView()
if (!activeView) return false
if (typeof (activeView as any).isWalkableWorldSubTile === 'function') {
return !(activeView as any).isWalkableWorldSubTile(subX, subY)
}
const localX = Math.floor(subX - (activeView.subOriginX ?? 0))
const localY = Math.floor(subY - (activeView.subOriginY ?? 0))
if (
localX < 0 ||
localY < 0 ||
localX >= activeView.subWidth ||
localY >= activeView.subHeight
) {
return false
}
const idx = localY * activeView.subWidth + localX
if (activeView.blocked && activeView.blocked[idx] === 1) return true
if (activeView.walkable) return activeView.walkable[idx] === 0
return false
})
this.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter()
this.soundService =
options.soundService ??
@ -1037,12 +1071,65 @@ export class OnlineSession {
: (cmd as ClientCommand)
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) {
this.world.noteOutgoingCommand(normalized)
this.forwardCommandToAdapter(normalized)
return true
}
return false
}
/**
* Alias for `dispatchCommand` used by input/HUD callers.
*/
sendCommand(cmd: ClientCommand | Record<string, any>, nowMs = performance.now()): boolean {
return this.dispatchCommand(cmd, nowMs)
}
/**
* Casts the active left or right skill at world sub-tile `(x, y)`.
*/
useSkillAt(
x: number,
y: number,
hand: 'left' | 'right' = 'right',
shift = false,
): boolean {
const cmd: ClientCommand = {
type: 'SkillAt',
hand,
at: { x: Math.round(x), y: Math.round(y) },
shift,
}
this.world.noteOutgoingCommand(cmd)
return this.dispatchCommand(cmd)
}
/**
* Casts the active left or right skill on target unit `(unitKind, unitId)`.
*/
useSkillOn(
unitKind: UnitType,
unitId: number,
hand: 'left' | 'right' = 'right',
shift = false,
): boolean {
const cmd: ClientCommand = {
type: 'SkillOn',
hand,
target: { unitType: unitKind, id: unitId },
shift,
}
this.world.noteOutgoingCommand(cmd)
return this.dispatchCommand(cmd)
}
/**
* Attacks target unit `(unitKind, unitId)` using the active left-hand skill.
*/
attackUnit(unitKind: UnitType, unitId: number, shift = false): boolean {
return this.useSkillOn(unitKind, unitId, 'left', shift)
}
private forwardCommandToAdapter(cmd: ClientCommand): void {
this.outboundCommandsLog.push(cmd)
if (this.outboundCommandsLog.length > 512) {
@ -1445,6 +1532,7 @@ export class OnlineSession {
}
}
this.world.tick(dtMs, nowMs)
this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
}
@ -1508,6 +1596,7 @@ export class OnlineSession {
isLocalPlayer: true,
isCorpse: isSelfDead,
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}),
})
}
@ -1523,6 +1612,30 @@ export class OnlineSession {
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
continue
}
if (u.unitType === 'missile') {
out.push({
id: u.id,
kind: 'missile',
classId: u.classId,
...(u.name !== undefined ? { name: u.name } : {}),
...(u.missileKey !== undefined ? { missileKey: u.missileKey } : {}),
x: u.x,
y: u.y,
...(u.targetX !== undefined ? { targetX: u.targetX } : {}),
...(u.targetY !== undefined ? { targetY: u.targetY } : {}),
...(u.vx !== undefined ? { vx: u.vx } : {}),
...(u.vy !== undefined ? { vy: u.vy } : {}),
...(u.frame !== undefined ? { frame: u.frame } : {}),
...(u.totalFrames !== undefined ? { totalFrames: u.totalFrames } : {}),
...(u.loopAnim !== undefined ? { loopAnim: u.loopAnim } : {}),
...(u.altitude !== undefined ? { altitude: u.altitude } : {}),
mode: u.mode,
direction: u.direction,
hpRatio: 1,
isCorpse: false,
})
continue
}
const interp = this.interpolator.getEntityState(
u.unitType === 'player'
? 0
@ -1530,11 +1643,9 @@ export class OnlineSession {
? 1
: u.unitType === 'object'
? 2
: u.unitType === 'missile'
? 3
: u.unitType === 'item'
? 4
: 5,
: u.unitType === 'item'
? 4
: 5,
u.id,
)
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
@ -1732,6 +1843,7 @@ export class OnlineSession {
isSuperUnique: u.isSuperUnique,
states: Array.from(u.states.list()),
...(castState !== undefined ? { castState } : {}),
...(u.overlays.length > 0 ? { overlays: u.overlays } : {}),
...(u.unitType === 'player' ? { isPartyMember } : {}),
})
}

View File

@ -1,6 +1,12 @@
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
import {
CANONICAL_113C_MISSILES,
CANONICAL_113C_OVERLAYS,
MISSILE_CLASS_ID_TO_KEY,
getMissileTxtData,
getSkillCastOverlay,
} from '../../common/skills/missiles-data.ts'
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts'
import { assertDir64, dir64FromPoints, dir64ToClipDirection } from '../../common/units/direction.ts'
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
import type { PointLightInput } from '../scene/lighting-presets.ts'
import type { UnitCastState } from '../world/client-unit.ts'
@ -16,8 +22,11 @@ export interface UnitOverlayState {
readonly token?: string | undefined
readonly frame?: number | undefined
readonly totalFrames?: number | undefined
readonly blendMode?: DrawableBlendMode | undefined
readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined
readonly yOffset?: number | undefined
readonly loop?: boolean | undefined
readonly lightRadius?: number | undefined
readonly lightColor?: readonly [number, number, number] | undefined
}
/**
@ -38,11 +47,17 @@ export interface RenderableUnitInput {
readonly mode?: number | undefined
/**
* Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept
* by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no
* by the motion layer; 0 (south) when absent. Missiles: the 16-direction index or dir64 used when no
* target is given.
*/
readonly direction?: number | undefined
readonly frame?: number | undefined
readonly totalFrames?: number | undefined
readonly loopAnim?: boolean | undefined
readonly missileKey?: string | undefined
readonly vx?: number | undefined
readonly vy?: number | undefined
readonly altitude?: number | undefined
readonly lifePct?: number | undefined
readonly name?: string | undefined
readonly code?: string | undefined
@ -121,6 +136,11 @@ export interface SceneMissileDrawable {
readonly celFile: string
readonly direction: number
readonly frame: number
readonly totalFrames?: number | undefined
readonly loopAnim?: boolean | undefined
readonly vx?: number | undefined
readonly vy?: number | undefined
readonly altitude?: number | undefined
readonly subX: number
readonly subY: number
readonly worldX: number
@ -139,6 +159,7 @@ export interface SceneOverlayDrawable {
readonly token: string
readonly frame: number
readonly totalFrames: number
readonly loop?: boolean | undefined
readonly worldX: number
readonly worldY: number
readonly sortY: number
@ -208,7 +229,10 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
return token
}
if (unit.kind === 'missile') {
const key = MISSILE_ID_TO_KEY[unit.classId]
const key =
unit.missileKey && CANONICAL_113C_MISSILES[unit.missileKey.toLowerCase()]
? unit.missileKey.toLowerCase()
: (MISSILE_ID_TO_KEY[unit.classId] ?? MISSILE_CLASS_ID_TO_KEY[unit.classId])
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
}
@ -237,36 +261,40 @@ export function resolveMissileMetadata(
classId: number,
explicitToken?: string,
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
const tokenKey =
explicitToken && explicitToken.trim() !== '' ? explicitToken.trim().toLowerCase() : undefined
const key =
explicitToken && explicitToken.trim() !== ''
? explicitToken.trim().toLowerCase()
: MISSILE_ID_TO_KEY[classId]
if (!key) return null
tokenKey && CANONICAL_113C_MISSILES[tokenKey]
? tokenKey
: (MISSILE_ID_TO_KEY[classId] ?? MISSILE_CLASS_ID_TO_KEY[classId])
if (!key || !CANONICAL_113C_MISSILES[key]) return null
return {
key,
entry: getMissileTxtData(key),
}
}
function computeDirection8Or16(
fromX: number,
fromY: number,
toX: number | undefined,
toY: number | undefined,
fallbackDir = 0,
numDirs = 16,
function computeMissileClipDirection(
unit: RenderableUnitInput,
numDirs: number,
): number {
if (toX === undefined || toY === undefined) {
return ((fallbackDir % numDirs) + numDirs) % numDirs
const validDirs = [1, 2, 4, 8, 16, 32, 64].includes(numDirs) ? numDirs : 16
if (validDirs === 1) return 0
const fallbackDir64 = Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64
? (unit.direction as number)
: 0
if (unit.vx !== undefined && unit.vy !== undefined && (Math.abs(unit.vx) > 1e-5 || Math.abs(unit.vy) > 1e-5)) {
const dir64 = dir64FromPoints(0, 0, unit.vx, unit.vy, fallbackDir64)
return dir64ToClipDirection(dir64, validDirs)
}
const dx = toX - fromX
const dy = toY - fromY
if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) {
return ((fallbackDir % numDirs) + numDirs) % numDirs
if (unit.targetX !== undefined && unit.targetY !== undefined) {
const dir64 = dir64FromPoints(unit.x, unit.y, unit.targetX, unit.targetY, fallbackDir64)
return dir64ToClipDirection(dir64, validDirs)
}
const angle = Math.atan2(dy, dx)
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)
return Math.floor(normalized * numDirs) % numDirs
if (Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64) {
return dir64ToClipDirection(unit.direction as number, validDirs)
}
return (((unit.direction ?? 0) % validDirs) + validDirs) % validDirs
}
/**
@ -345,19 +373,17 @@ export function buildSceneDrawables(
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
if (unit.kind === 'missile') {
const resolved = resolveMissileMetadata(unit.classId, unit.token)
const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name)
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
const { key, entry } = resolved
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
const dir = computeDirection8Or16(
unit.x,
unit.y,
unit.targetX,
unit.targetY,
unit.direction ?? 0,
16,
)
const frame = unit.frame ?? (tick % 8)
const dir = computeMissileClipDirection(unit, entry.numDirections || 16)
const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8)
const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0
const rawFrame = unit.frame ?? tick
const frame = loopAnim
? ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames
: Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame)))
const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0
const isCold = entry.blue > entry.red
const lightColor: readonly [number, number, number] = [
@ -374,6 +400,11 @@ export function buildSceneDrawables(
celFile: entry.celFile,
direction: dir,
frame,
totalFrames,
loopAnim,
...(unit.vx !== undefined ? { vx: unit.vx } : {}),
...(unit.vy !== undefined ? { vy: unit.vy } : {}),
...(unit.altitude !== undefined ? { altitude: unit.altitude } : {}),
subX: unit.x,
subY: unit.y,
worldX,
@ -404,11 +435,31 @@ export function buildSceneDrawables(
const unitOverlays: SceneOverlayDrawable[] = []
if (unit.overlays) {
for (const ov of unit.overlays) {
const ovTxt = ov.token ? CANONICAL_113C_OVERLAYS[ov.token.trim().toLowerCase()] : undefined
const meta = OVERLAY_METADATA[ov.overlayId]
const totalFrames = ov.totalFrames ?? meta?.totalFrames ?? 16
const frame = ov.frame ?? (tick % totalFrames)
const yOffset = ov.yOffset ?? meta?.yOffset ?? 0
const blendMode = ov.blendMode ?? meta?.blendMode ?? 'additive'
const totalFrames = Math.max(1, ov.totalFrames ?? ovTxt?.frames ?? meta?.totalFrames ?? 16)
const isOneShot = ov.loop === false || (ovTxt !== undefined && ov.loop !== true)
const rawFrame = ov.frame ?? tick
const frame = isOneShot
? Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame)))
: ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames
const yOffset =
ov.yOffset ??
(ovTxt?.yOffset !== undefined ? -ovTxt.yOffset : undefined) ??
meta?.yOffset ??
0
const blendMode: DrawableBlendMode =
typeof ov.blendMode === 'string'
? ov.blendMode
: typeof ov.blendMode === 'number'
? ov.blendMode === 0
? 'normal'
: 'additive'
: ovTxt
? ovTxt.trans === 0
? 'normal'
: 'additive'
: (meta?.blendMode ?? 'additive')
const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay',
ownerUnitId: unit.id,
@ -416,6 +467,7 @@ export function buildSceneDrawables(
token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`,
frame,
totalFrames,
loop: !isOneShot,
worldX,
worldY: worldY + yOffset,
sortY: worldY + 1,
@ -424,15 +476,23 @@ export function buildSceneDrawables(
unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable)
if (meta?.lightRadius && meta.lightColor) {
const lightRadius =
ov.lightRadius ??
(ovTxt && ovTxt.radius > 0 ? Math.max(12, ovTxt.radius) : meta?.lightRadius)
const lightColor: readonly [number, number, number] | undefined =
ov.lightColor ??
(ovTxt && (ovTxt.red > 0 || ovTxt.green > 0 || ovTxt.blue > 0)
? [ovTxt.red, ovTxt.green, ovTxt.blue]
: meta?.lightColor)
if (lightRadius && lightColor) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: meta.lightRadius,
intensity: 190,
r: meta.lightColor[0],
g: meta.lightColor[1],
b: meta.lightColor[2],
radius: lightRadius,
intensity: 210,
r: lightColor[0],
g: lightColor[1],
b: lightColor[2],
})
}
}
@ -457,6 +517,7 @@ export function buildSceneDrawables(
token: meta.token,
frame: tick % meta.totalFrames,
totalFrames: meta.totalFrames,
loop: true,
worldX,
worldY: worldY + meta.yOffset,
sortY: worldY + 1,
@ -496,16 +557,55 @@ export function buildSceneDrawables(
? 'SC'
: undefined)
if (activeCastState && effectiveMode === 10) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: 10,
intensity: 210,
r: 255,
g: 195,
b: 110,
})
if (activeCastState) {
const castOverlayKey =
activeCastState.castOverlayToken ?? getSkillCastOverlay(activeCastState.skillId)
if (
castOverlayKey &&
!unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase())
) {
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
if (ovTxt) {
const totalFrames = Math.max(1, ovTxt.frames)
const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick)))
const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay',
ownerUnitId: unit.id,
overlayId: activeCastState.skillId || 1000,
token: castOverlayKey,
frame,
totalFrames,
loop: false,
worldX,
worldY: worldY - (ovTxt.yOffset ?? 0),
sortY: worldY + 1,
blendMode: ovTxt.trans === 0 ? 'normal' : 'additive',
}
unitOverlays.push(ovDrawable)
overlayDrawables.push(ovDrawable)
if (ovTxt.radius > 0) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: Math.max(12, ovTxt.radius),
intensity: 215,
r: ovTxt.red,
g: ovTxt.green,
b: ovTxt.blue,
})
}
}
} else if (effectiveMode === 10) {
pointLights.push({
subTileX: Math.round(unit.x),
subTileY: Math.round(unit.y),
radius: 10,
intensity: 210,
r: 255,
g: 195,
b: 110,
})
}
}
const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY)

View File

@ -17,6 +17,22 @@ import {
import { StatList } from './stat-list.ts'
import { UnitStateSet } from './states.ts'
export interface ClientUnitOverlay {
readonly overlayId: number
readonly token: string
frame: number
readonly totalFrames: number
readonly animRate: number
readonly loop: boolean
readonly blendMode: 0 | 1 | 2 | 3
readonly yOffset: number
readonly startedAtMs: number
ageTicks: number
readonly maxTicks: number
readonly lightRadius: number
readonly lightColor: readonly [number, number, number]
}
export interface UnitCastState {
readonly skillId: number
readonly targetX: number
@ -24,6 +40,7 @@ export interface UnitCastState {
readonly mode: number
readonly startedAtMs: number
readonly untilMs: number
readonly castOverlayToken?: string | undefined
}
export function makeUnitKey(unitType: UnitType, id: number): string {
@ -62,6 +79,35 @@ export class ClientUnit {
overheadText?: string | undefined
itemData?: ItemData | undefined
castState?: UnitCastState | undefined
lastSkillId?: number | undefined
overlays: ClientUnitOverlay[] = []
// Missile simulation fields (populated when unitType === 'missile')
missileKey?: string | undefined
vx?: number | undefined
vy?: number | undefined
ageTicks?: number | undefined
maxTicks?: number | undefined
frame?: number | undefined
totalFrames?: number | undefined
animSpeed?: number | undefined
loopAnim?: boolean | undefined
numDirections?: number | undefined
explosionMissile?: string | undefined
alwaysExplode?: boolean | undefined
pierce?: boolean | undefined
hitUnitIds?: Set<number> | undefined
altitude?: number | undefined
originX?: number | undefined
originY?: number | undefined
spiralAngle?: number | undefined
spiralRadius?: number | undefined
jitterIndex?: number | undefined
emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined
subMissileIdx?: number | undefined
startedAtMs?: number | undefined
startedAtTick?: number | undefined
sourceSkillId?: number | undefined
skillLevel?: number | undefined
readonly stats = new StatList()
readonly states = new UnitStateSet()

File diff suppressed because it is too large Load Diff

View File

@ -216,6 +216,8 @@ export interface MissileTxtData {
readonly trans?: number | undefined
readonly subLoop?: number | undefined
readonly subStop?: number | undefined
readonly alwaysExplode?: number | boolean | undefined
readonly pierce?: number | boolean | undefined
}
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
@ -1518,8 +1520,248 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
trans: 1,
distancePx: 750,
},
bolt: {
name: 'bolt',
id: 1,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'Spike',
animLen: 1,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
coldarrow: {
name: 'coldarrow',
id: 13,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'ColdArrow',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 3,
red: 112,
green: 160,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
guidedarrow: {
name: 'guidedarrow',
id: 31,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
explodingarrow: {
name: 'explodingarrow',
id: 41,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'FireArrow',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'explodingarrowexp',
alwaysExplode: true,
light: 3,
red: 255,
green: 175,
blue: 55,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
immolationarrow: {
name: 'immolationarrow',
id: 63,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'FireArrow',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'explodingarrowexp',
alwaysExplode: true,
light: 5,
red: 255,
green: 175,
blue: 55,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
immolationfire: {
name: 'immolationfire',
id: 64,
vel: 0,
maxVel: 0,
range: 75,
levRange: 0,
celFile: 'FireSmall',
animLen: 21,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 3,
red: 255,
green: 185,
blue: 75,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
clientAlias: true,
},
freezingarrow: {
name: 'freezingarrow',
id: 87,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'ColdArrow',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'freezingarrowexp1',
alwaysExplode: true,
light: 3,
red: 112,
green: 160,
blue: 255,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
foa: {
name: 'foa',
id: 211,
vel: 0,
maxVel: 0,
range: 25,
levRange: 0,
celFile: 'FistOfTheHeavensDelay',
animLen: 5,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: 'fistoftheheavensbolt',
alwaysExplode: true,
light: 8,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
clientAlias: true,
},
fistoftheheavensbolt: {
name: 'fistoftheheavensbolt',
id: 212,
vel: 24,
maxVel: 24,
range: 25,
levRange: 0,
celFile: 'HolyBolt',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
trans: 1,
distancePx: 600,
clientAlias: true,
},
strafearrow: {
name: 'strafearrow',
id: 215,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 1,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 600,
trans: 0,
distancePx: 960,
clientAlias: true,
},
})
/**
* Lookup from 1.13c Missiles.txt numeric `Id` to canonical missile key.
*/
export const MISSILE_CLASS_ID_TO_KEY: Readonly<Record<number, string>> = Object.freeze(
(() => {
const map: Record<number, string> = {}
for (const [key, data] of Object.entries(CANONICAL_113C_MISSILES)) {
if (!data.clientAlias || map[data.id] === undefined) {
map[data.id] = key
}
}
return map
})(),
)
/**
* Safely parse an RGB color channel (0..255) with fallback (default 255).
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
@ -3677,6 +3919,262 @@ export const CANONICAL_113C_OVERLAYS: Readonly<Record<string, OverlayTxtData>> =
xOffset: 0,
yOffset: 0,
},
bonearmor_cast: {
overlay: 'bonearmor_cast',
filename: 'NecromancerSpellCast',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 8,
red: 215,
green: 215,
blue: 235,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
bonecast: {
overlay: 'bonecast',
filename: 'NecromancerSpellCast',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 8,
red: 215,
green: 215,
blue: 235,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
holy_bolt_cast: {
overlay: 'holy_bolt_cast',
filename: 'HolyBoltCast',
frames: 14,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
fist_of_the_heavens_cast: {
overlay: 'fist_of_the_heavens_cast',
filename: 'FistOfTheHeavensCast',
frames: 12,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
conversioncast: {
overlay: 'conversioncast',
filename: 'ConversionCast',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 7,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cast_innersight: {
overlay: 'cast_innersight',
filename: 'CastInnerSight',
frames: 16,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 7,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
cast_slowmissiles: {
overlay: 'cast_slowmissiles',
filename: 'CastSlowMissiles',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 7,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
valkyriestart: {
overlay: 'valkyriestart',
filename: 'ValkyrieStart',
frames: 20,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 235,
blue: 155,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
shoutstart: {
overlay: 'shoutstart',
filename: 'ShoutCast',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 1,
radius: 8,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
battleorderscast: {
overlay: 'battleorderscast',
filename: 'BattleOrdersCast',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 1,
radius: 8,
red: 155,
green: 205,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
battlecommandcast: {
overlay: 'battlecommandcast',
filename: 'BattleCommandCast',
frames: 20,
animRate: 16,
trans: 3,
preDraw: true,
initRadius: 1,
radius: 8,
red: 255,
green: 185,
blue: 115,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
druid_fire_cast_1: {
overlay: 'druid_fire_cast_1',
filename: 'DruidFireCast1',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 8,
red: 255,
green: 175,
blue: 55,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
druid_fire_cast_2: {
overlay: 'druid_fire_cast_2',
filename: 'DruidFireCast2',
frames: 17,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 9,
red: 255,
green: 175,
blue: 55,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
wolf_into: {
overlay: 'wolf_into',
filename: 'WolfTransform',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
bear_into: {
overlay: 'bear_into',
filename: 'BearTransform',
frames: 15,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 0,
radius: 0,
red: 255,
green: 255,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
lightning: {
overlay: 'lightning',
filename: 'LightHit',
frames: 12,
animRate: 16,
trans: 3,
preDraw: false,
initRadius: 1,
radius: 8,
red: 192,
green: 215,
blue: 255,
numDirections: 1,
xOffset: 0,
yOffset: 0,
},
})
/**
@ -4085,12 +4583,87 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly<Record<string, string>> = Object
'bone wall': 'bonecast',
boneprison: 'bonecast',
'bone prison': 'bonecast',
teeth: 'bonecast',
bonespear: 'bonecast',
'bone spear': 'bonecast',
bonespirit: 'bonecast',
'bone spirit': 'bonecast',
corpseexplosion: 'bonecast',
'corpse explosion': 'bonecast',
poisonexplosion: 'bonecast',
'poison explosion': 'bonecast',
poisonnova: 'bonecast',
'poison nova': 'bonecast',
amplifydamage: 'cursecast',
'amplify damage': 'cursecast',
dimvision: 'cursecast',
'dim vision': 'cursecast',
weaken: 'cursecast',
ironmaiden: 'cursecast',
'iron maiden': 'cursecast',
terror: 'cursecast',
confuse: 'cursecast',
lifetap: 'cursecast',
'life tap': 'cursecast',
attract: 'cursecast',
decrepify: 'cursecast',
lowerresist: 'cursecast',
'lower resist': 'cursecast',
holybolt: 'holy_bolt_cast',
'holy bolt': 'holy_bolt_cast',
blessedhammer: 'holy_bolt_cast',
'blessed hammer': 'holy_bolt_cast',
fistoftheheavens: 'fist_of_the_heavens_cast',
'fist of the heavens': 'fist_of_the_heavens_cast',
})
const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
66: 'cursecast', // Amplify Damage
67: 'bonecast', // Teeth
68: 'bonearmor_cast', // Bone Armor
70: 'bonecast', // Raise Skeleton
71: 'cursecast', // Dim Vision
72: 'cursecast', // Weaken
74: 'bonecast', // Corpse Explosion
76: 'cursecast', // Iron Maiden
77: 'cursecast', // Terror
78: 'bonecast', // Bone Wall
80: 'bonecast', //Raise Skeletal Mage
81: 'cursecast', // Confuse
82: 'cursecast', // Life Tap
83: 'bonecast', // Poison Explosion
84: 'bonecast', // Bone Spear
86: 'cursecast', // Attract
87: 'cursecast', // Decrepify
88: 'bonecast', // Bone Prison
91: 'cursecast', // Lower Resist
92: 'bonecast', // Poison Nova
93: 'bonecast', // Bone Spirit
95: 'bonecast', // Revive
})
const OTHER_CLASS_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
// Amazon
8: 'cast_innersight', // Inner Sight
17: 'cast_slowmissiles', // Slow Missiles
32: 'valkyriestart', // Valkyrie
// Paladin
101: 'holy_bolt_cast', // Holy Bolt
112: 'holy_bolt_cast', // Blessed Hammer
116: 'conversioncast', // Conversion
121: 'fist_of_the_heavens_cast', // Fist of the Heavens
// Barbarian
138: 'shoutstart', // Shout
149: 'battleorderscast', // Battle Orders
155: 'battlecommandcast', // Battle Command
// Druid
223: 'wolf_into', // Werewolf
225: 'druid_fire_cast_1', // Firestorm
228: 'bear_into', // Werebear
229: 'druid_fire_cast_1', // Molten Boulder
234: 'druid_fire_cast_1', // Fissure
244: 'druid_fire_cast_2', // Volcano
249: 'druid_fire_cast_2', // Armageddon
})
/**
@ -4101,6 +4674,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine
return (
SORCERESS_SKILL_CAST_OVERLAYS[skillId] ??
NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ??
OTHER_CLASS_SKILL_CAST_OVERLAYS[skillId] ??
BATCH1_SKILLS[skillId]?.castOverlay
)
}
@ -4109,6 +4683,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine
return (
SORCERESS_SKILL_CAST_OVERLAYS[num] ??
NECROMANCER_SKILL_CAST_OVERLAYS[num] ??
OTHER_CLASS_SKILL_CAST_OVERLAYS[num] ??
BATCH1_SKILLS[num]?.castOverlay
)
}

View File

@ -71,12 +71,17 @@ export function decode0x73CreateMissile(pkt: Uint8Array): ServerEvent {
const y = r.u16LE()
const targetX = r.u16LE()
const targetY = r.u16LE()
const level = r.remaining >= 1 ? r.u8() : 1
const ownerType = r.remaining >= 1 ? clampUnitType(r.u8()) : 0
const ownerId = r.remaining >= 4 ? r.u32LE() : 0
return {
type: 'SkillCast',
caster: { unitType: 3, id: missileClassId },
skill: missileClassId,
level: 1,
level: level || 1,
at: { x, y },
target: { x: targetX || x, y: targetY || y },
owner: { unitType: ownerType, id: ownerId },
}
}

View File

@ -86,6 +86,8 @@ export type ServerEvent =
readonly skillId?: number | undefined
readonly level: number
readonly target: UnitRef | Pt
readonly at?: Pt | undefined
readonly owner?: UnitRef | undefined
}
| {
readonly type: 'StatSet'

View File

@ -679,6 +679,291 @@ describe('ClientWorld authoritative state mirror', () => {
expect(world.containers.partnerTradeGrid).toHaveLength(0)
})
it('R1: attaches canonical 1.13c cast overlays, deduplicates local cast echoes within 250ms, and expires castState to neutral mode 1', async () => {
const { getSkillCastOverlay } = await import('../../src/common/skills/missiles-data.ts')
expect(getSkillCastOverlay(36)).toBe('fire_cast_1')
expect(getSkillCastOverlay(39)).toBe('ice_cast_1')
expect(getSkillCastOverlay(44)).toBe('ice_cast_2')
expect(getSkillCastOverlay(47)).toBe('fire_cast_2')
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
expect(getSkillCastOverlay(54)).toBe('teleport')
expect(getSkillCastOverlay(62)).toBe('fire_cast_2')
expect(getSkillCastOverlay(64)).toBe('ice_cast_3')
expect(getSkillCastOverlay(66)).toBe('cursecast')
expect(getSkillCastOverlay(67)).toBe('bonecast')
expect(getSkillCastOverlay(68)).toBe('bonearmor_cast')
expect(getSkillCastOverlay(84)).toBe('bonecast')
expect(getSkillCastOverlay(101)).toBe('holy_bolt_cast')
expect(getSkillCastOverlay(112)).toBe('holy_bolt_cast')
expect(getSkillCastOverlay(121)).toBe('fist_of_the_heavens_cast')
expect(getSkillCastOverlay(8)).toBe('cast_innersight')
expect(getSkillCastOverlay(32)).toBe('valkyriestart')
expect(getSkillCastOverlay(138)).toBe('shoutstart')
expect(getSkillCastOverlay(149)).toBe('battleorderscast')
expect(getSkillCastOverlay(155)).toBe('battlecommandcast')
expect(getSkillCastOverlay(225)).toBe('druid_fire_cast_1')
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
world.apply({ type: 'ActiveSkill', unitId: 1, hand: 'right', skill: 36 })
// Local cast command attaches fire_cast_1 overlay and spawns 1 firebolt missile
const castCmd: ClientCommand = {
type: 'SkillAt',
hand: 'right',
at: { x: 5020, y: 5000 },
shift: false,
}
world.noteOutgoingCommand(castCmd)
// Calling noteOutgoingCommand again with the same command object is idempotent
world.noteOutgoingCommand(castCmd)
const selfUnit = world.getUnit(0, 1)!
expect(selfUnit.mode).toBe(10)
expect(selfUnit.castState?.skillId).toBe(36)
expect(selfUnit.castState?.castOverlayToken).toBe('fire_cast_1')
expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1' && o.loop === false)).toBe(true)
const initialMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile')
expect(initialMissiles).toHaveLength(1)
expect(initialMissiles[0]?.missileKey).toBe('firebolt')
// Server echo within 250ms must not duplicate the cast overlay or spawn a second firebolt
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36,
level: 1,
target: { x: 5020, y: 5000 },
})
const afterEchoMissiles = Array.from(world.units.values()).filter(u => u.unitType === 'missile')
expect(afterEchoMissiles).toHaveLength(1)
expect(selfUnit.overlays?.filter(o => o.token === 'fire_cast_1')).toHaveLength(1)
// Stepping ticks advances the one-shot overlay and expires castState back to mode 1 (Neutral)
const startMs = selfUnit.castState!.startedAtMs
for (let i = 1; i <= 15; i++) {
world.tick(40, startMs + i * 40)
}
expect(selfUnit.castState).toBeUndefined()
expect(world.self.castState).toBeUndefined()
expect(selfUnit.mode).toBe(1)
expect(selfUnit.overlays?.some(o => o.token === 'fire_cast_1')).toBe(false)
})
it('R2: decodes 0x73 CreateMissile and simulates 1.13c linear, fan, radial ring, spiral, emitter, and skyfall trajectories at 25Hz', async () => {
const { decodeD2gsS2cPacket } = await import('../../src/netproto/index.ts')
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
// 1. 0x73 CreateMissile wire packet decoding & ClientWorld reduction (missileId=62 fireball at (5002,5004) -> (5022,5004))
const raw0x73 = new Uint8Array(32)
const dv = new DataView(raw0x73.buffer)
raw0x73[0] = 0x73
dv.setUint32(1, 0, true) // unk
dv.setUint16(5, 62, true) // missileClassId = 62 (fireball)
dv.setUint16(7, 5002, true) // x
dv.setUint16(9, 5004, true) // y
dv.setUint16(11, 5022, true) // targetX
dv.setUint16(13, 5004, true) // targetY
raw0x73[15] = 5 // level = 5
raw0x73[16] = 0 // ownerType = player
dv.setUint32(17, 2, true) // ownerId = 2 (remote player)
const ev0x73 = decodeD2gsS2cPacket(raw0x73)
expect(ev0x73.type).toBe('SkillCast')
if (ev0x73.type === 'SkillCast') {
expect(ev0x73.caster).toEqual({ unitType: 3, id: 62 })
expect(ev0x73.skill).toBe(62)
expect(ev0x73.at).toEqual({ x: 5002, y: 5004 })
expect(ev0x73.target).toEqual({ x: 5022, y: 5004 })
expect(ev0x73.level).toBe(5)
expect(ev0x73.owner).toEqual({ unitType: 0, id: 2 })
}
world.apply(ev0x73)
const spawnedFireball = Array.from(world.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'fireball',
)
expect(spawnedFireball).toBeDefined()
expect(spawnedFireball?.x).toBe(5002)
expect(spawnedFireball?.y).toBe(5004)
world.tick(40, Date.now() + 40)
expect(spawnedFireball!.x).toBeGreaterThan(5002)
// 2. Charged Bolt (skill 38, level 3 -> 5 bolts in a fan)
const cbWorld = new ClientWorld()
cbWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 },
})
cbWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 38,
skillId: 38,
level: 3,
target: { x: 5015, y: 5000 },
})
const chargedBolts = Array.from(cbWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'chargedbolt',
)
expect(chargedBolts).toHaveLength(5)
// 3. Frost Nova (skill 44 -> 32 radial projectiles)
const fnWorld = new ClientWorld()
fnWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'RemoteSorc', x: 5000, y: 5000 },
})
fnWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 44,
skillId: 44,
level: 1,
target: { x: 5005, y: 5005 },
})
const frostNovas = Array.from(fnWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'frostnova',
)
expect(frostNovas).toHaveLength(32)
// 4. Blessed Hammer (skill 112 -> spiral trajectory)
const bhWorld = new ClientWorld()
bhWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 3, classId: 3, name: 'Hammerdin', x: 5000, y: 5000 },
})
bhWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 3 },
skill: 112,
skillId: 112,
level: 1,
target: { x: 5005, y: 5000 },
})
const hammer = Array.from(bhWorld.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'blessedhammer',
)!
expect(hammer).toBeDefined()
const r0 = hammer.spiralRadius ?? 0
bhWorld.tick(120, Date.now() + 120)
expect(hammer.spiralRadius).toBeGreaterThan(r0)
expect(Math.hypot(hammer.x - 5000, hammer.y - 5000)).toBeGreaterThan(0.5)
// 5. Frozen Orb (skill 64 -> emits frozenorbbolt sub-missiles during flight)
const foWorld = new ClientWorld()
foWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 1, name: 'OrbSorc', x: 5000, y: 5000 },
})
foWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 64,
skillId: 64,
level: 1,
target: { x: 5030, y: 5000 },
})
foWorld.tick(160, Date.now() + 160) // 4 ticks
const subBolts = Array.from(foWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'frozenorbbolt',
)
expect(subBolts.length).toBeGreaterThanOrEqual(4)
})
it('R3: detects wall and hostile monster collisions, respects pierce, and spawns one-shot 1.13c ExplosionMissile visuals', () => {
// 1. Blocked sub-tile collision -> spawns fireexplode and removes firebolt
const wallWorld = new ClientWorld()
wallWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
wallWorld.setSubTileBlocked(5005, 5000, true)
wallWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 36,
skillId: 36, // Fire Bolt (vel=20 -> 2.5 sub-tiles/tick)
level: 1,
target: { x: 5020, y: 5000 },
})
wallWorld.tick(80, Date.now() + 80) // 2 ticks -> reaches x=5005
const afterWallUnits = Array.from(wallWorld.units.values()).filter(u => u.unitType === 'missile')
expect(afterWallUnits.some(u => u.missileKey === 'firebolt')).toBe(false)
const wallExplosion = afterWallUnits.find(u => u.missileKey === 'fireexplode')
expect(wallExplosion).toBeDefined()
expect(wallExplosion?.loopAnim).toBe(false)
// Step explosion past its lifetime -> clamps frame and removes upon completion
for (let i = 1; i <= 15; i++) {
wallWorld.tick(40, Date.now() + 80 + i * 40)
}
expect(
Array.from(wallWorld.units.values()).some(u => u.missileKey === 'fireexplode'),
).toBe(false)
// 2. Hostile monster collision: non-piercing Ice Blast vs piercing Bone Spear
const monWorld = new ClientWorld()
monWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'SorcHero', x: 5000, y: 5000 },
isSelf: true,
})
monWorld.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 99, classId: 1, name: 'Fallen', x: 5003, y: 5000, lifePct: 100 },
})
// Ice Blast (skill 45) hits Fallen at (5003, 5000), spawns freezeexplode + glacialspikeejecta, and is removed
monWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skill: 45,
skillId: 45,
level: 1,
target: { x: 5020, y: 5000 },
})
monWorld.tick(80, Date.now() + 80)
const monMissiles = Array.from(monWorld.units.values()).filter(u => u.unitType === 'missile')
expect(monMissiles.some(u => u.missileKey === 'iceblast')).toBe(false)
expect(monMissiles.some(u => u.missileKey === 'freezeexplode')).toBe(true)
// Piercing Bone Spear (skill 84) passes through Fallen without being destroyed
const pierceWorld = new ClientWorld()
pierceWorld.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 2, classId: 2, name: 'Necro', x: 5000, y: 5000 },
})
pierceWorld.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 100, classId: 1, name: 'Zombie', x: 5003, y: 5000, lifePct: 100 },
})
pierceWorld.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 2 },
skill: 84,
skillId: 84,
level: 1,
target: { x: 5030, y: 5000 },
})
pierceWorld.tick(120, Date.now() + 120)
const boneSpear = Array.from(pierceWorld.units.values()).find(
u => u.unitType === 'missile' && u.missileKey === 'bonespear',
)
expect(boneSpear).toBeDefined()
expect(boneSpear?.hitUnitIds?.has(100)).toBe(true)
expect(boneSpear!.x).toBeGreaterThan(5005)
})
it('Issue #551: initializes alive monsters/NPCs (e.g. Rogue Scout) in MONMODE_NEUTRAL (1) instead of MONMODE_DEATH (0) and transitions mode on UnitMove', () => {
const world = new ClientWorld()
@ -773,3 +1058,4 @@ describe('ClientWorld authoritative state mirror', () => {
}
})
})

View File

@ -1031,5 +1031,86 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
session.destroy()
}
})
it('projects canonical 1.13c spell cast overlays, flying missiles, LevelView wall collisions, and one-shot impact explosions into SceneSource drawables (#550)', () => {
const session = new OnlineSession({
settingsStore: createSettingsStore(),
})
const walkable = new Uint8Array(200 * 200).fill(1)
const blocked = new Uint8Array(200 * 200)
// Block sub-tile at world (5056, 5050) -> local (56, 50)
walkable[50 * 200 + 56] = 0
blocked[50 * 200 + 56] = 1
session.levelView = {
act: 0,
areaId: 2,
subOriginX: 5000,
subOriginY: 5000,
subWidth: 200,
subHeight: 200,
isoOriginX: 0,
isoOriginY: 0,
cellsX: 40,
cellsY: 40,
gridWidth: 200,
blocked,
walkable,
collisionMasks: new Uint16Array(200 * 200),
rooms: [],
floors: [],
shadows: [],
walls: [],
roofs: [],
warps: [],
presets: [],
} as any
session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x11223344, areaId: 2 })
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 1, name: 'Sorceress', x: 5050, y: 5050 },
isSelf: true,
})
session.handleServerEvent({
type: 'ActiveSkill',
unitId: 1,
hand: 'right',
skill: 47, // Fire Ball
})
// Cast Fire Ball toward (5070, 5050); wall is at (5056, 5050)
session.useSkillAt(5070, 5050, 'right', true)
const snap0 = session.sceneSource.buildFrameSnapshot()
expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true)
const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball')
expect(fireball0).toBeDefined()
expect(fireball0?.blendMode).toBe('additive')
expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2)
// Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050)
// and spawns one-shot explodingarrowexp
session.tick(120, 1120)
const snapImpact = session.sceneSource.buildFrameSnapshot()
expect(snapImpact.entities.missileDrawables.some((m) => m.missileKey === 'fireball')).toBe(false)
const expDrawable = snapImpact.entities.missileDrawables.find(
(m) => m.missileKey === 'explodingarrowexp',
)
expect(expDrawable).toBeDefined()
expect(expDrawable?.loopAnim).toBe(false)
// Advance past explosion and cast duration -> explosion removed and caster returns to neutral
for (let i = 1; i <= 20; i++) {
session.tick(40, 1120 + i * 40)
}
const snapAfter = session.sceneSource.buildFrameSnapshot()
expect(snapAfter.entities.missileDrawables).toHaveLength(0)
expect(snapAfter.entities.overlayDrawables).toHaveLength(0)
session.destroy()
})
})
})