fix(client): restore canonical 1.13c spell visual effects (#550) #554
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts'
|
||||||
import { dir64ToClipDirection } from '../../common/units/direction.ts'
|
import { dir64ToClipDirection } from '../../common/units/direction.ts'
|
||||||
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts'
|
||||||
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
import { npcDisplayName } from '../../common/units/npc-table.ts'
|
||||||
|
|
@ -734,6 +735,9 @@ function drawSceneMissileDrawable(
|
||||||
ctx: SceneSourceRenderContext,
|
ctx: SceneSourceRenderContext,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
): void {
|
): void {
|
||||||
|
if (missile.celFile.toLowerCase() === 'sqr') {
|
||||||
|
return
|
||||||
|
}
|
||||||
const art =
|
const art =
|
||||||
ctx.missileArtMap?.get(missile.missileKey) ??
|
ctx.missileArtMap?.get(missile.missileKey) ??
|
||||||
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
|
ctx.missileArtMap?.get(missile.celFile.toLowerCase())
|
||||||
|
|
@ -742,17 +746,21 @@ function drawSceneMissileDrawable(
|
||||||
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
|
const dir = ((missile.direction % dirCount) + dirCount) % dirCount
|
||||||
const group = art.frames[dir] ?? art.frames[0]
|
const group = art.frames[dir] ?? art.frames[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
const frameIdx = (missile.frame + Math.floor(nowMs / 50)) % frameCount
|
const frameIdx =
|
||||||
|
missile.loopAnim === false
|
||||||
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(missile.frame)))
|
||||||
|
: (missile.frame + Math.floor(nowMs / 50)) % frameCount
|
||||||
const frame = group?.[frameIdx] ?? group?.[0]
|
const frame = group?.[frameIdx] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
|
const altitude = missile.altitude ?? 0
|
||||||
const drawX =
|
const drawX =
|
||||||
frame.anchorX !== undefined
|
frame.anchorX !== undefined
|
||||||
? missile.worldX + frame.anchorX
|
? missile.worldX + frame.anchorX
|
||||||
: missile.worldX - frame.width / 2
|
: missile.worldX - frame.width / 2
|
||||||
const drawY =
|
const drawY =
|
||||||
frame.anchorY !== undefined
|
(frame.anchorY !== undefined
|
||||||
? missile.worldY + frame.anchorY
|
? missile.worldY + frame.anchorY
|
||||||
: missile.worldY - frame.height / 2
|
: missile.worldY - frame.height / 2) - altitude
|
||||||
renderer.draw(frame, drawX, drawY, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
atlas: art.handle,
|
atlas: art.handle,
|
||||||
blendMode: missile.blendMode,
|
blendMode: missile.blendMode,
|
||||||
|
|
@ -773,13 +781,25 @@ function drawSceneOverlayDrawable(
|
||||||
ctx: SceneSourceRenderContext,
|
ctx: SceneSourceRenderContext,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
): void {
|
): void {
|
||||||
|
const lowerToken = overlay.token.toLowerCase()
|
||||||
|
const celFile = CANONICAL_113C_OVERLAYS[lowerToken]?.filename
|
||||||
|
const lowerCel = celFile?.toLowerCase()
|
||||||
const art =
|
const art =
|
||||||
ctx.overlayArtMap?.get(overlay.token) ??
|
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) {
|
if (art !== undefined) {
|
||||||
const group = art.frames[0]
|
const group = art.frames[0]
|
||||||
const frameCount = Math.max(1, group?.length ?? 1)
|
const frameCount = Math.max(1, group?.length ?? 1)
|
||||||
const frameIdx = (overlay.frame + Math.floor(nowMs / 60)) % frameCount
|
const frameIdx =
|
||||||
|
overlay.loop === false
|
||||||
|
? Math.min(frameCount - 1, Math.max(0, Math.floor(overlay.frame)))
|
||||||
|
: (overlay.frame + Math.floor(nowMs / 60)) % frameCount
|
||||||
const frame = group?.[frameIdx] ?? group?.[0]
|
const frame = group?.[frameIdx] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX =
|
const drawX =
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,40 @@ export class OnlineSession {
|
||||||
|
|
||||||
this.predictor = options.predictor ?? new LocalMovementPredictor()
|
this.predictor = options.predictor ?? new LocalMovementPredictor()
|
||||||
this.interpolator = options.interpolator ?? new RemoteEntityInterpolator()
|
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.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter()
|
||||||
this.soundService =
|
this.soundService =
|
||||||
options.soundService ??
|
options.soundService ??
|
||||||
|
|
@ -955,12 +989,65 @@ export class OnlineSession {
|
||||||
: (cmd as ClientCommand)
|
: (cmd as ClientCommand)
|
||||||
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
|
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
|
||||||
if (res.allowed) {
|
if (res.allowed) {
|
||||||
|
this.world.noteOutgoingCommand(normalized)
|
||||||
this.forwardCommandToAdapter(normalized)
|
this.forwardCommandToAdapter(normalized)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
private forwardCommandToAdapter(cmd: ClientCommand): void {
|
||||||
this.outboundCommandsLog.push(cmd)
|
this.outboundCommandsLog.push(cmd)
|
||||||
if (this.outboundCommandsLog.length > 512) {
|
if (this.outboundCommandsLog.length > 512) {
|
||||||
|
|
@ -1305,6 +1392,8 @@ export class OnlineSession {
|
||||||
unit.direction = state.dir64
|
unit.direction = state.dir64
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.world.tick(dtMs, nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1353,6 +1442,7 @@ export class OnlineSession {
|
||||||
isLocalPlayer: true,
|
isLocalPlayer: true,
|
||||||
isCorpse: isSelfDead,
|
isCorpse: isSelfDead,
|
||||||
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
|
...(selfCastState !== undefined ? { castState: selfCastState } : {}),
|
||||||
|
...(selfUnit && selfUnit.overlays.length > 0 ? { overlays: selfUnit.overlays } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1371,6 +1461,30 @@ export class OnlineSession {
|
||||||
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
if (u.unitType === 'player' && this.world.selfId !== 0 && u.id === this.world.selfId) {
|
||||||
continue
|
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(
|
const interp = this.interpolator.getEntityState(
|
||||||
u.unitType === 'player'
|
u.unitType === 'player'
|
||||||
? 0
|
? 0
|
||||||
|
|
@ -1378,11 +1492,9 @@ export class OnlineSession {
|
||||||
? 1
|
? 1
|
||||||
: u.unitType === 'object'
|
: u.unitType === 'object'
|
||||||
? 2
|
? 2
|
||||||
: u.unitType === 'missile'
|
: u.unitType === 'item'
|
||||||
? 3
|
? 4
|
||||||
: u.unitType === 'item'
|
: 5,
|
||||||
? 4
|
|
||||||
: 5,
|
|
||||||
u.id,
|
u.id,
|
||||||
)
|
)
|
||||||
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
|
const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined
|
||||||
|
|
@ -1543,6 +1655,7 @@ export class OnlineSession {
|
||||||
isSuperUnique: u.isSuperUnique,
|
isSuperUnique: u.isSuperUnique,
|
||||||
states: Array.from(u.states.list()),
|
states: Array.from(u.states.list()),
|
||||||
...(castState !== undefined ? { castState } : {}),
|
...(castState !== undefined ? { castState } : {}),
|
||||||
|
...(u.overlays.length > 0 ? { overlays: u.overlays } : {}),
|
||||||
...(u.unitType === 'player' ? { isPartyMember } : {}),
|
...(u.unitType === 'player' ? { isPartyMember } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
||||||
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
||||||
import type { UnitCastState } from '../world/client-unit.ts'
|
import type { UnitCastState } from '../world/client-unit.ts'
|
||||||
|
|
@ -16,8 +22,11 @@ export interface UnitOverlayState {
|
||||||
readonly token?: string | undefined
|
readonly token?: string | undefined
|
||||||
readonly frame?: number | undefined
|
readonly frame?: number | undefined
|
||||||
readonly totalFrames?: number | undefined
|
readonly totalFrames?: number | undefined
|
||||||
readonly blendMode?: DrawableBlendMode | undefined
|
readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined
|
||||||
readonly yOffset?: number | 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
|
readonly mode?: number | undefined
|
||||||
/**
|
/**
|
||||||
* Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept
|
* 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.
|
* target is given.
|
||||||
*/
|
*/
|
||||||
readonly direction?: number | undefined
|
readonly direction?: number | undefined
|
||||||
readonly frame?: 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 lifePct?: number | undefined
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
readonly code?: string | undefined
|
readonly code?: string | undefined
|
||||||
|
|
@ -121,6 +136,11 @@ export interface SceneMissileDrawable {
|
||||||
readonly celFile: string
|
readonly celFile: string
|
||||||
readonly direction: number
|
readonly direction: number
|
||||||
readonly frame: 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 subX: number
|
||||||
readonly subY: number
|
readonly subY: number
|
||||||
readonly worldX: number
|
readonly worldX: number
|
||||||
|
|
@ -139,6 +159,7 @@ export interface SceneOverlayDrawable {
|
||||||
readonly token: string
|
readonly token: string
|
||||||
readonly frame: number
|
readonly frame: number
|
||||||
readonly totalFrames: number
|
readonly totalFrames: number
|
||||||
|
readonly loop?: boolean | undefined
|
||||||
readonly worldX: number
|
readonly worldX: number
|
||||||
readonly worldY: number
|
readonly worldY: number
|
||||||
readonly sortY: number
|
readonly sortY: number
|
||||||
|
|
@ -208,7 +229,10 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
if (unit.kind === 'missile') {
|
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.
|
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
|
||||||
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
|
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
|
||||||
}
|
}
|
||||||
|
|
@ -237,36 +261,40 @@ export function resolveMissileMetadata(
|
||||||
classId: number,
|
classId: number,
|
||||||
explicitToken?: string,
|
explicitToken?: string,
|
||||||
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
|
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
|
||||||
|
const tokenKey =
|
||||||
|
explicitToken && explicitToken.trim() !== '' ? explicitToken.trim().toLowerCase() : undefined
|
||||||
const key =
|
const key =
|
||||||
explicitToken && explicitToken.trim() !== ''
|
tokenKey && CANONICAL_113C_MISSILES[tokenKey]
|
||||||
? explicitToken.trim().toLowerCase()
|
? tokenKey
|
||||||
: MISSILE_ID_TO_KEY[classId]
|
: (MISSILE_ID_TO_KEY[classId] ?? MISSILE_CLASS_ID_TO_KEY[classId])
|
||||||
if (!key) return null
|
if (!key || !CANONICAL_113C_MISSILES[key]) return null
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
entry: getMissileTxtData(key),
|
entry: getMissileTxtData(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeDirection8Or16(
|
function computeMissileClipDirection(
|
||||||
fromX: number,
|
unit: RenderableUnitInput,
|
||||||
fromY: number,
|
numDirs: number,
|
||||||
toX: number | undefined,
|
|
||||||
toY: number | undefined,
|
|
||||||
fallbackDir = 0,
|
|
||||||
numDirs = 16,
|
|
||||||
): number {
|
): number {
|
||||||
if (toX === undefined || toY === undefined) {
|
const validDirs = [1, 2, 4, 8, 16, 32, 64].includes(numDirs) ? numDirs : 16
|
||||||
return ((fallbackDir % numDirs) + numDirs) % numDirs
|
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
|
if (unit.targetX !== undefined && unit.targetY !== undefined) {
|
||||||
const dy = toY - fromY
|
const dir64 = dir64FromPoints(unit.x, unit.y, unit.targetX, unit.targetY, fallbackDir64)
|
||||||
if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) {
|
return dir64ToClipDirection(dir64, validDirs)
|
||||||
return ((fallbackDir % numDirs) + numDirs) % numDirs
|
|
||||||
}
|
}
|
||||||
const angle = Math.atan2(dy, dx)
|
if (Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64) {
|
||||||
const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2)
|
return dir64ToClipDirection(unit.direction as number, validDirs)
|
||||||
return Math.floor(normalized * numDirs) % numDirs
|
}
|
||||||
|
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)
|
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
|
||||||
|
|
||||||
if (unit.kind === 'missile') {
|
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).
|
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
|
||||||
const { key, entry } = resolved
|
const { key, entry } = resolved
|
||||||
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
|
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
|
||||||
const dir = computeDirection8Or16(
|
const dir = computeMissileClipDirection(unit, entry.numDirections || 16)
|
||||||
unit.x,
|
const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8)
|
||||||
unit.y,
|
const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0
|
||||||
unit.targetX,
|
const rawFrame = unit.frame ?? tick
|
||||||
unit.targetY,
|
const frame = loopAnim
|
||||||
unit.direction ?? 0,
|
? ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames
|
||||||
16,
|
: Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame)))
|
||||||
)
|
|
||||||
const frame = unit.frame ?? (tick % 8)
|
|
||||||
const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0
|
const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0
|
||||||
const isCold = entry.blue > entry.red
|
const isCold = entry.blue > entry.red
|
||||||
const lightColor: readonly [number, number, number] = [
|
const lightColor: readonly [number, number, number] = [
|
||||||
|
|
@ -374,6 +400,11 @@ export function buildSceneDrawables(
|
||||||
celFile: entry.celFile,
|
celFile: entry.celFile,
|
||||||
direction: dir,
|
direction: dir,
|
||||||
frame,
|
frame,
|
||||||
|
totalFrames,
|
||||||
|
loopAnim,
|
||||||
|
...(unit.vx !== undefined ? { vx: unit.vx } : {}),
|
||||||
|
...(unit.vy !== undefined ? { vy: unit.vy } : {}),
|
||||||
|
...(unit.altitude !== undefined ? { altitude: unit.altitude } : {}),
|
||||||
subX: unit.x,
|
subX: unit.x,
|
||||||
subY: unit.y,
|
subY: unit.y,
|
||||||
worldX,
|
worldX,
|
||||||
|
|
@ -404,11 +435,31 @@ export function buildSceneDrawables(
|
||||||
const unitOverlays: SceneOverlayDrawable[] = []
|
const unitOverlays: SceneOverlayDrawable[] = []
|
||||||
if (unit.overlays) {
|
if (unit.overlays) {
|
||||||
for (const ov of 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 meta = OVERLAY_METADATA[ov.overlayId]
|
||||||
const totalFrames = ov.totalFrames ?? meta?.totalFrames ?? 16
|
const totalFrames = Math.max(1, ov.totalFrames ?? ovTxt?.frames ?? meta?.totalFrames ?? 16)
|
||||||
const frame = ov.frame ?? (tick % totalFrames)
|
const isOneShot = ov.loop === false || (ovTxt !== undefined && ov.loop !== true)
|
||||||
const yOffset = ov.yOffset ?? meta?.yOffset ?? 0
|
const rawFrame = ov.frame ?? tick
|
||||||
const blendMode = ov.blendMode ?? meta?.blendMode ?? 'additive'
|
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 = {
|
const ovDrawable: SceneOverlayDrawable = {
|
||||||
drawableKind: 'overlay',
|
drawableKind: 'overlay',
|
||||||
ownerUnitId: unit.id,
|
ownerUnitId: unit.id,
|
||||||
|
|
@ -416,6 +467,7 @@ export function buildSceneDrawables(
|
||||||
token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`,
|
token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`,
|
||||||
frame,
|
frame,
|
||||||
totalFrames,
|
totalFrames,
|
||||||
|
loop: !isOneShot,
|
||||||
worldX,
|
worldX,
|
||||||
worldY: worldY + yOffset,
|
worldY: worldY + yOffset,
|
||||||
sortY: worldY + 1,
|
sortY: worldY + 1,
|
||||||
|
|
@ -424,15 +476,23 @@ export function buildSceneDrawables(
|
||||||
unitOverlays.push(ovDrawable)
|
unitOverlays.push(ovDrawable)
|
||||||
overlayDrawables.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({
|
pointLights.push({
|
||||||
subTileX: Math.round(unit.x),
|
subTileX: Math.round(unit.x),
|
||||||
subTileY: Math.round(unit.y),
|
subTileY: Math.round(unit.y),
|
||||||
radius: meta.lightRadius,
|
radius: lightRadius,
|
||||||
intensity: 190,
|
intensity: 210,
|
||||||
r: meta.lightColor[0],
|
r: lightColor[0],
|
||||||
g: meta.lightColor[1],
|
g: lightColor[1],
|
||||||
b: meta.lightColor[2],
|
b: lightColor[2],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -457,6 +517,7 @@ export function buildSceneDrawables(
|
||||||
token: meta.token,
|
token: meta.token,
|
||||||
frame: tick % meta.totalFrames,
|
frame: tick % meta.totalFrames,
|
||||||
totalFrames: meta.totalFrames,
|
totalFrames: meta.totalFrames,
|
||||||
|
loop: true,
|
||||||
worldX,
|
worldX,
|
||||||
worldY: worldY + meta.yOffset,
|
worldY: worldY + meta.yOffset,
|
||||||
sortY: worldY + 1,
|
sortY: worldY + 1,
|
||||||
|
|
@ -496,16 +557,55 @@ export function buildSceneDrawables(
|
||||||
? 'SC'
|
? 'SC'
|
||||||
: undefined)
|
: undefined)
|
||||||
|
|
||||||
if (activeCastState && effectiveMode === 10) {
|
if (activeCastState) {
|
||||||
pointLights.push({
|
const castOverlayKey =
|
||||||
subTileX: Math.round(unit.x),
|
activeCastState.castOverlayToken ?? getSkillCastOverlay(activeCastState.skillId)
|
||||||
subTileY: Math.round(unit.y),
|
if (
|
||||||
radius: 10,
|
castOverlayKey &&
|
||||||
intensity: 210,
|
!unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase())
|
||||||
r: 255,
|
) {
|
||||||
g: 195,
|
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
|
||||||
b: 110,
|
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)
|
const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,22 @@ import {
|
||||||
import { StatList } from './stat-list.ts'
|
import { StatList } from './stat-list.ts'
|
||||||
import { UnitStateSet } from './states.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 {
|
export interface UnitCastState {
|
||||||
readonly skillId: number
|
readonly skillId: number
|
||||||
readonly targetX: number
|
readonly targetX: number
|
||||||
|
|
@ -24,6 +40,7 @@ export interface UnitCastState {
|
||||||
readonly mode: number
|
readonly mode: number
|
||||||
readonly startedAtMs: number
|
readonly startedAtMs: number
|
||||||
readonly untilMs: number
|
readonly untilMs: number
|
||||||
|
readonly castOverlayToken?: string | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeUnitKey(unitType: UnitType, id: number): string {
|
export function makeUnitKey(unitType: UnitType, id: number): string {
|
||||||
|
|
@ -62,6 +79,35 @@ export class ClientUnit {
|
||||||
overheadText?: string | undefined
|
overheadText?: string | undefined
|
||||||
itemData?: ItemData | undefined
|
itemData?: ItemData | undefined
|
||||||
castState?: UnitCastState | 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 stats = new StatList()
|
||||||
readonly states = new UnitStateSet()
|
readonly states = new UnitStateSet()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,23 @@ import {
|
||||||
type SkillLevel,
|
type SkillLevel,
|
||||||
type UnitRef,
|
type UnitRef,
|
||||||
type UnitType,
|
type UnitType,
|
||||||
|
type UnitTypeName,
|
||||||
} from '../../netproto/index.ts'
|
} from '../../netproto/index.ts'
|
||||||
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
import { onlineItemDataToUiInventoryItem } from '../../common/items/item-bridge.ts'
|
||||||
|
import {
|
||||||
|
CANONICAL_113C_MISSILES,
|
||||||
|
MISSILE_CLASS_ID_TO_KEY,
|
||||||
|
getMissileTxtData,
|
||||||
|
getOverlayTxtData,
|
||||||
|
getSkillCastOverlay,
|
||||||
|
} from '../../common/skills/missiles-data.ts'
|
||||||
import { dir64FromPoints } from '../../common/units/direction.ts'
|
import { dir64FromPoints } from '../../common/units/direction.ts'
|
||||||
import { ClientUnit, makeUnitKey, type UnitCastState } from './client-unit.ts'
|
import {
|
||||||
|
ClientUnit,
|
||||||
|
makeUnitKey,
|
||||||
|
type ClientUnitOverlay,
|
||||||
|
type UnitCastState,
|
||||||
|
} from './client-unit.ts'
|
||||||
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
|
import { ClientInventoryManager, type ClientItemContainers } from './inventory.ts'
|
||||||
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
|
import { MutableSelfPlayerState, type SelfPlayerState } from './self.ts'
|
||||||
|
|
||||||
|
|
@ -143,9 +156,74 @@ const MAX_D2_STAT_ID = 0x1ff
|
||||||
|
|
||||||
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
|
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
|
||||||
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
|
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
|
||||||
0, 2, 6, 7, 10, 19, 20, 24, 96, 97, 106, 107, 116, 126, 133, 139, 140, 144, 147, 151, 152,
|
0, 2, 6, 7, 10, 11, 12, 16, 19, 20, 21, 22, 24, 26, 27, 31, 96, 97, 106, 107, 116, 126, 133, 139,
|
||||||
|
140, 144, 147, 151, 152,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** Single-bolt linear projectile skills (1.13c Skills.txt srvmissile / cltmissilea). */
|
||||||
|
const LINEAR_SKILL_MISSILES: Readonly<Record<number, { key: string; pierce?: boolean }>> =
|
||||||
|
Object.freeze({
|
||||||
|
6: { key: 'magicarrow' },
|
||||||
|
7: { key: 'firearrow' },
|
||||||
|
11: { key: 'coldarrow' },
|
||||||
|
16: { key: 'explodingarrow' },
|
||||||
|
21: { key: 'icearrow' },
|
||||||
|
22: { key: 'guidedarrow' },
|
||||||
|
26: { key: 'strafearrow' },
|
||||||
|
27: { key: 'immolationarrow' },
|
||||||
|
31: { key: 'freezingarrow' },
|
||||||
|
36: { key: 'firebolt' },
|
||||||
|
39: { key: 'icebolt' },
|
||||||
|
45: { key: 'iceblast' },
|
||||||
|
47: { key: 'fireball' },
|
||||||
|
49: { key: 'lightning', pierce: true },
|
||||||
|
53: { key: 'chainlightning', pierce: true },
|
||||||
|
55: { key: 'glacialspike' },
|
||||||
|
84: { key: 'bonespear', pierce: true },
|
||||||
|
93: { key: 'bonespirit' },
|
||||||
|
101: { key: 'holybolt' },
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Radial ring skills (32 bolts in 360 degrees around caster). */
|
||||||
|
const RADIAL_RING_SKILLS: Readonly<Record<number, string>> = Object.freeze({
|
||||||
|
44: 'frostnova',
|
||||||
|
48: 'nova',
|
||||||
|
92: 'poisonnova',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Necromancer curse target overlays (1.13c Skills.txt / Overlay.txt). */
|
||||||
|
const CURSE_TARGET_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
|
||||||
|
66: 'curseampplydamage',
|
||||||
|
71: 'cursedimvision',
|
||||||
|
72: 'curseweaken',
|
||||||
|
76: 'curseironmaiden',
|
||||||
|
77: 'curseterror',
|
||||||
|
81: 'curseconfuse',
|
||||||
|
82: 'curselifetap',
|
||||||
|
86: 'curseattract',
|
||||||
|
87: 'cursedecrepify',
|
||||||
|
91: 'curselowerresist',
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface SpawnMissileOptions {
|
||||||
|
readonly id?: number | undefined
|
||||||
|
readonly classId?: number | undefined
|
||||||
|
readonly ownerType?: UnitTypeName | undefined
|
||||||
|
readonly ownerId?: number | undefined
|
||||||
|
readonly sourceSkillId?: number | undefined
|
||||||
|
readonly skillLevel?: number | undefined
|
||||||
|
readonly pierce?: boolean | undefined
|
||||||
|
readonly loopAnim?: boolean | undefined
|
||||||
|
readonly maxTicks?: number | undefined
|
||||||
|
readonly altitude?: number | undefined
|
||||||
|
readonly jitterIndex?: number | undefined
|
||||||
|
readonly emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined
|
||||||
|
readonly originX?: number | undefined
|
||||||
|
readonly originY?: number | undefined
|
||||||
|
readonly spiralAngle?: number | undefined
|
||||||
|
readonly spiralRadius?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
export class ClientWorld {
|
export class ClientWorld {
|
||||||
act = 0
|
act = 0
|
||||||
mapSeed = 0
|
mapSeed = 0
|
||||||
|
|
@ -195,6 +273,16 @@ export class ClientWorld {
|
||||||
private _npcTransactionSeq = 0
|
private _npcTransactionSeq = 0
|
||||||
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
private readonly _changeListeners = new Set<(ev: ServerEvent) => void>()
|
||||||
|
|
||||||
|
private _nextMissileId = 0x70000000
|
||||||
|
private _nextOverlayId = 1
|
||||||
|
private _lastLocalCastSkillId = -1
|
||||||
|
private _lastLocalCastAtMs = -1000
|
||||||
|
private _simTimeMs = 0
|
||||||
|
private readonly _castSimStartMs = new Map<string, number>()
|
||||||
|
private _notedCommands = new WeakSet<object>()
|
||||||
|
private readonly _blockedSubTiles = new Set<string>()
|
||||||
|
private _collisionChecker: ((subX: number, subY: number) => boolean) | null = null
|
||||||
|
|
||||||
get npcInteraction(): NpcInteractionState {
|
get npcInteraction(): NpcInteractionState {
|
||||||
return this._npcInteraction
|
return this._npcInteraction
|
||||||
}
|
}
|
||||||
|
|
@ -216,15 +304,435 @@ export class ClientWorld {
|
||||||
return this._mercHireList
|
return this._mercHireList
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySkillCastToUnit(unit: ClientUnit, skillId: number, targetX: number, targetY: number): void {
|
/**
|
||||||
|
* Registers an optional world sub-tile collision callback (`true` = blocked wall/obstacle).
|
||||||
|
*/
|
||||||
|
setCollisionChecker(checker: ((subX: number, subY: number) => boolean) | null): void {
|
||||||
|
this._collisionChecker = checker
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks or unmarks an individual sub-tile `(subX, subY)` as blocked for missile collision.
|
||||||
|
*/
|
||||||
|
setSubTileBlocked(subX: number, subY: number, blocked = true): void {
|
||||||
|
const key = `${Math.floor(subX)},${Math.floor(subY)}`
|
||||||
|
if (blocked) {
|
||||||
|
this._blockedSubTiles.add(key)
|
||||||
|
} else {
|
||||||
|
this._blockedSubTiles.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` when sub-tile `(subX, subY)` is blocked by a map wall/obstacle.
|
||||||
|
*/
|
||||||
|
isSubTileBlocked(subX: number, subY: number): boolean {
|
||||||
|
const ix = Math.floor(subX)
|
||||||
|
const iy = Math.floor(subY)
|
||||||
|
if (this._blockedSubTiles.has(`${ix},${iy}`)) return true
|
||||||
|
if (this._collisionChecker) {
|
||||||
|
return Boolean(this._collisionChecker(ix, iy))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches a 1.13c Overlay.txt visual onto `unit.overlays`.
|
||||||
|
*/
|
||||||
|
attachUnitOverlay(unit: ClientUnit, overlayToken: string, loop = false): ClientUnitOverlay {
|
||||||
|
const cleanToken = overlayToken.trim().toLowerCase()
|
||||||
|
const ovData = getOverlayTxtData(cleanToken)
|
||||||
|
const totalFrames = Math.max(1, ovData.frames || 14)
|
||||||
|
const animRate = Math.max(1, ovData.animRate || 16)
|
||||||
|
const maxTicks = Math.max(1, Math.ceil((totalFrames * 16) / animRate))
|
||||||
|
const blendMode: 0 | 1 | 2 | 3 =
|
||||||
|
ovData.trans === 0 ? 0 : ovData.trans === 1 ? 1 : ovData.trans === 2 ? 2 : 3
|
||||||
|
const entry: ClientUnitOverlay = {
|
||||||
|
overlayId: this._nextOverlayId++,
|
||||||
|
token: cleanToken,
|
||||||
|
frame: 0,
|
||||||
|
totalFrames,
|
||||||
|
animRate,
|
||||||
|
loop,
|
||||||
|
blendMode,
|
||||||
|
yOffset: ovData.yOffset ?? 0,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
ageTicks: 0,
|
||||||
|
maxTicks,
|
||||||
|
lightRadius: ovData.radius ?? 0,
|
||||||
|
lightColor: [ovData.red ?? 255, ovData.green ?? 255, ovData.blue ?? 255] as const,
|
||||||
|
}
|
||||||
|
unit.overlays = unit.overlays.filter(o => o.token !== cleanToken || o.loop !== loop)
|
||||||
|
unit.overlays.push(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns a client-side missile unit (`unitType = 'missile'`) initialized from 1.13c `Missiles.txt`.
|
||||||
|
*/
|
||||||
|
spawnMissileByKey(
|
||||||
|
missileKey: string,
|
||||||
|
startX: number,
|
||||||
|
startY: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
opts?: SpawnMissileOptions,
|
||||||
|
): ClientUnit {
|
||||||
|
const cleanKey = missileKey.trim().toLowerCase()
|
||||||
|
const data = getMissileTxtData(cleanKey)
|
||||||
|
const canonical = CANONICAL_113C_MISSILES[cleanKey]
|
||||||
|
const classId = opts?.classId ?? (canonical?.id && canonical.id > 0 ? canonical.id : data.id || 1)
|
||||||
|
const id = opts?.id ?? this._nextMissileId++
|
||||||
|
const level = Math.max(1, opts?.skillLevel ?? 1)
|
||||||
|
|
||||||
|
const dx = targetX - startX
|
||||||
|
const dy = targetY - startY
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
const dir64 = dir64FromPoints(startX, startY, targetX, targetY, 0)
|
||||||
|
|
||||||
|
const speedSubTilesPerTick = data.vel > 0 ? data.vel / 8 : 0
|
||||||
|
let vx = 0
|
||||||
|
let vy = 0
|
||||||
|
if (speedSubTilesPerTick > 0) {
|
||||||
|
if (dist > 1e-5) {
|
||||||
|
vx = (dx / dist) * speedSubTilesPerTick
|
||||||
|
vy = (dy / dist) * speedSubTilesPerTick
|
||||||
|
} else {
|
||||||
|
vx = speedSubTilesPerTick
|
||||||
|
vy = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFrames = Math.max(1, data.animLen || 1)
|
||||||
|
const animSpeed = Math.max(1, data.animSpeed || 16)
|
||||||
|
const loopAnim = opts?.loopAnim ?? (data.loopAnim !== 0)
|
||||||
|
const defaultMaxTicks =
|
||||||
|
!loopAnim && data.vel === 0
|
||||||
|
? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed))
|
||||||
|
: Math.max(1, (data.range || 20) + Math.max(0, level - 1) * (data.levRange ?? 0))
|
||||||
|
const maxTicks = opts?.maxTicks ?? defaultMaxTicks
|
||||||
|
|
||||||
|
const unit = new ClientUnit({
|
||||||
|
unitType: 'missile',
|
||||||
|
id,
|
||||||
|
classId,
|
||||||
|
name: cleanKey,
|
||||||
|
x: startX,
|
||||||
|
y: startY,
|
||||||
|
mode: 1,
|
||||||
|
direction: dir64,
|
||||||
|
ownerType: opts?.ownerType,
|
||||||
|
ownerId: opts?.ownerId,
|
||||||
|
})
|
||||||
|
unit.missileKey = cleanKey
|
||||||
unit.targetX = targetX
|
unit.targetX = targetX
|
||||||
unit.targetY = targetY
|
unit.targetY = targetY
|
||||||
|
unit.vx = vx
|
||||||
|
unit.vy = vy
|
||||||
|
unit.ageTicks = 0
|
||||||
|
unit.maxTicks = maxTicks
|
||||||
|
unit.frame = 0
|
||||||
|
unit.totalFrames = totalFrames
|
||||||
|
unit.animSpeed = animSpeed
|
||||||
|
unit.loopAnim = loopAnim
|
||||||
|
unit.numDirections = data.numDirections || 1
|
||||||
|
const rawRow = CANONICAL_113C_MISSILES[cleanKey]
|
||||||
|
unit.explosionMissile = data.explosionMissile || ''
|
||||||
|
unit.alwaysExplode = Boolean(rawRow?.alwaysExplode)
|
||||||
|
unit.pierce = opts?.pierce ?? Boolean(rawRow?.pierce)
|
||||||
|
unit.hitUnitIds = new Set<number>()
|
||||||
|
unit.altitude = opts?.altitude
|
||||||
|
unit.originX = opts?.originX ?? startX
|
||||||
|
unit.originY = opts?.originY ?? startY
|
||||||
|
unit.spiralAngle = opts?.spiralAngle
|
||||||
|
unit.spiralRadius = opts?.spiralRadius
|
||||||
|
unit.jitterIndex = opts?.jitterIndex
|
||||||
|
unit.emitterKind = opts?.emitterKind
|
||||||
|
unit.startedAtMs = Date.now()
|
||||||
|
unit.startedAtTick = this.frameCount
|
||||||
|
unit.sourceSkillId = opts?.sourceSkillId
|
||||||
|
unit.skillLevel = level
|
||||||
|
|
||||||
|
this._units.set(unit.key, unit)
|
||||||
|
return unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns canonical 1.13c projectile/ground/radial/spiral/emitter missiles for a skill cast.
|
||||||
|
*/
|
||||||
|
private spawnMissilesForSkill(
|
||||||
|
caster: ClientUnit,
|
||||||
|
skillId: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
skillLevel = 1,
|
||||||
|
): void {
|
||||||
|
const ownerType = caster.unitType
|
||||||
|
const ownerId = caster.id
|
||||||
|
const startX = caster.x
|
||||||
|
const startY = caster.y
|
||||||
|
const dx = targetX - startX
|
||||||
|
const dy = targetY - startY
|
||||||
|
const baseAngle = dx === 0 && dy === 0 ? 0 : Math.atan2(dy, dx)
|
||||||
|
|
||||||
|
// 1. Single-bolt linear projectiles
|
||||||
|
const linear = LINEAR_SKILL_MISSILES[skillId]
|
||||||
|
if (linear) {
|
||||||
|
const destX = dx === 0 && dy === 0 ? startX + 10 : targetX
|
||||||
|
const destY = dx === 0 && dy === 0 ? startY : targetY
|
||||||
|
this.spawnMissileByKey(linear.key, startX, startY, destX, destY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
pierce: linear.pierce,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
|
||||||
|
if (skillId === 38 || skillId === 67 || skillId === 12) {
|
||||||
|
const missileKey =
|
||||||
|
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multiple shot'
|
||||||
|
const count =
|
||||||
|
skillId === 38
|
||||||
|
? Math.min(24, 2 + Math.max(1, skillLevel))
|
||||||
|
: Math.min(24, Math.max(2, 1 + Math.max(1, skillLevel)))
|
||||||
|
const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1))
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
|
||||||
|
const angle = baseAngle + offset
|
||||||
|
const tx = startX + Math.cos(angle) * 20
|
||||||
|
const ty = startY + Math.sin(angle) * 20
|
||||||
|
this.spawnMissileByKey(missileKey, startX, startY, tx, ty, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
jitterIndex: skillId === 38 ? i + 1 : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Radial ring spells: Frost Nova (44), Nova (48), Poison Nova (92)
|
||||||
|
const radialKey = RADIAL_RING_SKILLS[skillId]
|
||||||
|
if (radialKey) {
|
||||||
|
const boltCount = 32
|
||||||
|
for (let i = 0; i < boltCount; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / boltCount
|
||||||
|
const tx = startX + Math.cos(angle) * 16
|
||||||
|
const ty = startY + Math.sin(angle) * 16
|
||||||
|
this.spawnMissileByKey(radialKey, startX, startY, tx, ty, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Spiral trajectory: Blessed Hammer (112)
|
||||||
|
if (skillId === 112) {
|
||||||
|
const tx = dx === 0 && dy === 0 ? startX + 8 : targetX
|
||||||
|
const ty = dx === 0 && dy === 0 ? startY : targetY
|
||||||
|
this.spawnMissileByKey('blessedhammer', startX, startY, tx, ty, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
pierce: true,
|
||||||
|
originX: startX,
|
||||||
|
originY: startY,
|
||||||
|
spiralAngle: baseAngle,
|
||||||
|
spiralRadius: 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sub-missile emitter: Frozen Orb (64)
|
||||||
|
if (skillId === 64) {
|
||||||
|
const tx = dx === 0 && dy === 0 ? startX + 16 : targetX
|
||||||
|
const ty = dx === 0 && dy === 0 ? startY : targetY
|
||||||
|
this.spawnMissileByKey('frozenorb', startX, startY, tx, ty, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
pierce: true,
|
||||||
|
emitterKind: 'frozenorb',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Ground / Skyfall / Instant-target spells
|
||||||
|
if (skillId === 42) {
|
||||||
|
// Static Field: spawn lightninghit on nearby alive monsters (or at caster/target)
|
||||||
|
let hitAny = false
|
||||||
|
for (const m of this._monsters.values()) {
|
||||||
|
if (m.dead || (this._merc && m.id === this._merc.mercId)) continue
|
||||||
|
if (Math.hypot(m.x - startX, m.y - startY) <= 15) {
|
||||||
|
this.spawnMissileByKey('lightninghit', m.x, m.y, m.x, m.y, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
loopAnim: false,
|
||||||
|
})
|
||||||
|
hitAny = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hitAny) {
|
||||||
|
this.spawnMissileByKey('lightninghit', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
loopAnim: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 43) {
|
||||||
|
// Telekinesis: lightninghit at target
|
||||||
|
this.spawnMissileByKey('lightninghit', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
loopAnim: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 46) {
|
||||||
|
// Blaze: stationary fire patch at caster's feet
|
||||||
|
this.spawnMissileByKey('blaze', startX, startY, startX, startY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 51) {
|
||||||
|
// Fire Wall: line of 5 fire segments perpendicular to caster->target direction
|
||||||
|
const perpAngle = baseAngle + Math.PI / 2
|
||||||
|
const perpX = Math.cos(perpAngle)
|
||||||
|
const perpY = Math.sin(perpAngle)
|
||||||
|
for (let i = -2; i <= 2; i++) {
|
||||||
|
const fx = targetX + perpX * i * 1.5
|
||||||
|
const fy = targetY + perpY * i * 1.5
|
||||||
|
this.spawnMissileByKey('firewall', fx, fy, fx, fy, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 56) {
|
||||||
|
// Meteor: target marker + descending skyfall meteor
|
||||||
|
this.spawnMissileByKey('meteorcenter', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
maxTicks: 25,
|
||||||
|
})
|
||||||
|
const fallTicks = 25
|
||||||
|
const mUnit = this.spawnMissileByKey(
|
||||||
|
'meteor',
|
||||||
|
targetX - 10,
|
||||||
|
targetY - 10,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
{
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
maxTicks: fallTicks,
|
||||||
|
altitude: 240,
|
||||||
|
pierce: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mUnit.vx = 10 / fallTicks
|
||||||
|
mUnit.vy = 10 / fallTicks
|
||||||
|
mUnit.explosionMissile = 'meteorexplode'
|
||||||
|
mUnit.alwaysExplode = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 59) {
|
||||||
|
// Blizzard: controller + initial falling shard
|
||||||
|
this.spawnMissileByKey('blizzardcenter', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
emitterKind: 'blizzard_controller',
|
||||||
|
maxTicks: 100,
|
||||||
|
})
|
||||||
|
const shard = this.spawnMissileByKey('blizzard1', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
maxTicks: 8,
|
||||||
|
})
|
||||||
|
shard.explosionMissile = 'freezeexplode'
|
||||||
|
shard.alwaysExplode = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillId === 121) {
|
||||||
|
// Fist of the Heavens
|
||||||
|
this.spawnMissileByKey('foa', targetX, targetY, targetX, targetY, {
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
sourceSkillId: skillId,
|
||||||
|
skillLevel,
|
||||||
|
maxTicks: 6,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Necromancer curses: attach target overlay to monsters near target
|
||||||
|
const curseOverlay = CURSE_TARGET_OVERLAYS[skillId]
|
||||||
|
if (curseOverlay) {
|
||||||
|
for (const m of this._monsters.values()) {
|
||||||
|
if (m.dead) continue
|
||||||
|
if (Math.hypot(m.x - targetX, m.y - targetY) <= 6) {
|
||||||
|
this.attachUnitOverlay(m, curseOverlay, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySkillCastToUnit(
|
||||||
|
unit: ClientUnit,
|
||||||
|
skillId: number,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
skillLevel = 1,
|
||||||
|
spawnVisuals = true,
|
||||||
|
): void {
|
||||||
|
unit.targetX = targetX
|
||||||
|
unit.targetY = targetY
|
||||||
|
unit.lastSkillId = skillId
|
||||||
// `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the
|
// `ClientUnit.direction` is a dir64 (`common/units/direction.ts`): face the target through the
|
||||||
// same 1.13c direction table as movement; a target on the unit's own position keeps the facing.
|
// same 1.13c direction table as movement; a target on the unit's own position keeps the facing.
|
||||||
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
|
unit.direction = dir64FromPoints(unit.x, unit.y, targetX, targetY, unit.direction)
|
||||||
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
|
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId)
|
||||||
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
|
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
|
||||||
unit.mode = mode
|
unit.mode = mode
|
||||||
|
const castOverlayToken = getSkillCastOverlay(skillId)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const castState: UnitCastState = {
|
const castState: UnitCastState = {
|
||||||
skillId,
|
skillId,
|
||||||
|
|
@ -233,11 +741,24 @@ export class ClientWorld {
|
||||||
mode,
|
mode,
|
||||||
startedAtMs: now,
|
startedAtMs: now,
|
||||||
untilMs: now + 400,
|
untilMs: now + 400,
|
||||||
|
castOverlayToken,
|
||||||
}
|
}
|
||||||
unit.castState = castState
|
unit.castState = castState
|
||||||
|
this._castSimStartMs.set(unit.key, this._simTimeMs)
|
||||||
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
||||||
this._self.castState = castState
|
this._self.castState = castState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (spawnVisuals) {
|
||||||
|
if (castOverlayToken) {
|
||||||
|
this.attachUnitOverlay(unit, castOverlayToken, false)
|
||||||
|
}
|
||||||
|
const effectiveLevel =
|
||||||
|
unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)
|
||||||
|
? Math.max(1, this._self.skills.get(skillId) ?? skillLevel)
|
||||||
|
: Math.max(1, skillLevel)
|
||||||
|
this.spawnMissilesForSkill(unit, skillId, targetX, targetY, effectiveLevel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -247,6 +768,10 @@ export class ClientWorld {
|
||||||
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
|
* sent, capture 05); 0x4F 0x12 closes the stash (no reply, capture 08).
|
||||||
*/
|
*/
|
||||||
noteOutgoingCommand(cmd: ClientCommand): void {
|
noteOutgoingCommand(cmd: ClientCommand): void {
|
||||||
|
if (cmd && typeof cmd === 'object') {
|
||||||
|
if (this._notedCommands.has(cmd)) return
|
||||||
|
this._notedCommands.add(cmd)
|
||||||
|
}
|
||||||
const cur = this._npcInteraction
|
const cur = this._npcInteraction
|
||||||
switch (cmd.type) {
|
switch (cmd.type) {
|
||||||
case 'NpcInit':
|
case 'NpcInit':
|
||||||
|
|
@ -316,6 +841,8 @@ export class ClientWorld {
|
||||||
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
|
||||||
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
|
||||||
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
|
||||||
|
this._lastLocalCastSkillId = skillId
|
||||||
|
this._lastLocalCastAtMs = Date.now()
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
const selfUnit = this.ensureUnit('player', this.selfId)
|
||||||
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
|
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY)
|
||||||
|
|
@ -333,6 +860,8 @@ export class ClientWorld {
|
||||||
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
|
||||||
const targetX = targetUnit?.x ?? this._self.x
|
const targetX = targetUnit?.x ?? this._self.x
|
||||||
const targetY = targetUnit?.y ?? this._self.y
|
const targetY = targetUnit?.y ?? this._self.y
|
||||||
|
this._lastLocalCastSkillId = skillId
|
||||||
|
this._lastLocalCastAtMs = Date.now()
|
||||||
if (this.selfId !== 0) {
|
if (this.selfId !== 0) {
|
||||||
const selfUnit = this.ensureUnit('player', this.selfId)
|
const selfUnit = this.ensureUnit('player', this.selfId)
|
||||||
selfUnit.targetUnit = targetRef
|
selfUnit.targetUnit = targetRef
|
||||||
|
|
@ -430,10 +959,322 @@ export class ClientWorld {
|
||||||
return this._soundQueue.splice(0, this._soundQueue.length)
|
return this._soundQueue.splice(0, this._soundQueue.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
tick(dtMs = 40): void {
|
private triggerMissileImpact(
|
||||||
|
missile: ClientUnit,
|
||||||
|
hitX: number,
|
||||||
|
hitY: number,
|
||||||
|
isWallHit: boolean,
|
||||||
|
): void {
|
||||||
|
const expKey =
|
||||||
|
missile.explosionMissile ||
|
||||||
|
(!isWallHit &&
|
||||||
|
(missile.missileKey === 'lightning' ||
|
||||||
|
missile.missileKey === 'chainlightning' ||
|
||||||
|
missile.missileKey === 'chargedbolt' ||
|
||||||
|
missile.missileKey === 'nova')
|
||||||
|
? 'lightninghit'
|
||||||
|
: '')
|
||||||
|
|
||||||
|
if (expKey) {
|
||||||
|
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
|
||||||
|
ownerType: missile.ownerType,
|
||||||
|
ownerId: missile.ownerId,
|
||||||
|
sourceSkillId: missile.sourceSkillId,
|
||||||
|
skillLevel: missile.skillLevel,
|
||||||
|
loopAnim: false,
|
||||||
|
})
|
||||||
|
exp.vx = 0
|
||||||
|
exp.vy = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain Lightning bounce to nearest unhit alive hostile monster within 10 sub-tiles
|
||||||
|
if (!isWallHit && missile.missileKey === 'chainlightning') {
|
||||||
|
let nearest: ClientUnit | null = null
|
||||||
|
let bestDist = 10
|
||||||
|
for (const m of this._monsters.values()) {
|
||||||
|
if (m.dead || (this._merc && m.id === this._merc.mercId)) continue
|
||||||
|
if (missile.hitUnitIds?.has(m.id)) continue
|
||||||
|
const d = Math.hypot(m.x - hitX, m.y - hitY)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
nearest = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest) {
|
||||||
|
const speed = Math.hypot(missile.vx ?? 0, missile.vy ?? 0) || 1.5
|
||||||
|
const dx = nearest.x - hitX
|
||||||
|
const dy = nearest.y - hitY
|
||||||
|
const dist = Math.hypot(dx, dy) || 1
|
||||||
|
missile.vx = (dx / dist) * speed
|
||||||
|
missile.vy = (dy / dist) * speed
|
||||||
|
missile.direction = dir64FromPoints(hitX, hitY, nearest.x, nearest.y, missile.direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stepMissileUnit(unit: ClientUnit): void {
|
||||||
|
const prevX = unit.x
|
||||||
|
const prevY = unit.y
|
||||||
|
const vx = unit.vx ?? 0
|
||||||
|
const vy = unit.vy ?? 0
|
||||||
|
const isMoving = vx !== 0 || vy !== 0 || unit.missileKey === 'blessedhammer'
|
||||||
|
|
||||||
|
if (isMoving) {
|
||||||
|
if (unit.missileKey === 'blessedhammer') {
|
||||||
|
unit.spiralAngle = (unit.spiralAngle ?? 0) + 0.28
|
||||||
|
unit.spiralRadius = (unit.spiralRadius ?? 0) + 0.45
|
||||||
|
const nextX = (unit.originX ?? unit.x) + Math.cos(unit.spiralAngle) * unit.spiralRadius
|
||||||
|
const nextY = (unit.originY ?? unit.y) + Math.sin(unit.spiralAngle) * unit.spiralRadius
|
||||||
|
unit.vx = nextX - unit.x
|
||||||
|
unit.vy = nextY - unit.y
|
||||||
|
unit.x = nextX
|
||||||
|
unit.y = nextY
|
||||||
|
} else if (unit.missileKey === 'chargedbolt') {
|
||||||
|
const speed = Math.hypot(vx, vy) || 1
|
||||||
|
const perpX = -vy / speed
|
||||||
|
const perpY = vx / speed
|
||||||
|
const phase = ((unit.ageTicks ?? 0) + (unit.jitterIndex ?? 0) * 3) % 4
|
||||||
|
const lateral = phase === 0 ? 0.35 : phase === 2 ? -0.35 : 0
|
||||||
|
unit.x += vx + perpX * lateral
|
||||||
|
unit.y += vy + perpY * lateral
|
||||||
|
} else {
|
||||||
|
unit.x += vx
|
||||||
|
unit.y += vy
|
||||||
|
if (unit.altitude !== undefined && unit.altitude > 0) {
|
||||||
|
const progress = Math.min(
|
||||||
|
1,
|
||||||
|
((unit.ageTicks ?? 0) + 1) / Math.max(1, unit.maxTicks ?? 1),
|
||||||
|
)
|
||||||
|
unit.altitude = Math.max(0, Math.round(240 * (1 - progress)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.direction = dir64FromPoints(0, 0, unit.vx ?? 0, unit.vy ?? 0, unit.direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
unit.ageTicks = (unit.ageTicks ?? 0) + 1
|
||||||
|
const animSpeed = Math.max(1, unit.animSpeed ?? 16)
|
||||||
|
const totalFrames = Math.max(1, unit.totalFrames ?? 1)
|
||||||
|
const rawFrame = Math.floor((unit.ageTicks * animSpeed) / 16)
|
||||||
|
unit.frame =
|
||||||
|
unit.loopAnim === false ? Math.min(totalFrames - 1, rawFrame) : rawFrame % totalFrames
|
||||||
|
|
||||||
|
// Sub-missile emitter logic
|
||||||
|
if (unit.emitterKind === 'frozenorb' && unit.ageTicks % 2 === 0) {
|
||||||
|
for (let b = 0; b < 2; b++) {
|
||||||
|
const angle = unit.ageTicks * 0.65 + b * Math.PI
|
||||||
|
const tx = unit.x + Math.cos(angle) * 16
|
||||||
|
const ty = unit.y + Math.sin(angle) * 16
|
||||||
|
this.spawnMissileByKey('frozenorbbolt', unit.x, unit.y, tx, ty, {
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (unit.emitterKind === 'blizzard_controller' && unit.ageTicks % 5 === 0) {
|
||||||
|
const idx = Math.floor(unit.ageTicks / 5)
|
||||||
|
const angle = idx * 2.39996 // golden angle distribution
|
||||||
|
const rad = (idx % 4) + 0.5
|
||||||
|
const sx = unit.x + Math.cos(angle) * rad
|
||||||
|
const sy = unit.y + Math.sin(angle) * rad
|
||||||
|
const shard = this.spawnMissileByKey('blizzard1', sx, sy, sx, sy, {
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
maxTicks: 8,
|
||||||
|
})
|
||||||
|
shard.explosionMissile = 'freezeexplode'
|
||||||
|
shard.alwaysExplode = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall / obstacle collision check along the step segment (only for ground-level flying projectiles)
|
||||||
|
if (isMoving && (unit.altitude ?? 0) <= 0) {
|
||||||
|
const stepDist = Math.hypot(unit.x - prevX, unit.y - prevY)
|
||||||
|
const samples = Math.max(1, Math.ceil(stepDist * 2))
|
||||||
|
for (let s = 1; s <= samples; s++) {
|
||||||
|
const t = s / samples
|
||||||
|
const sx = prevX + (unit.x - prevX) * t
|
||||||
|
const sy = prevY + (unit.y - prevY) * t
|
||||||
|
if (this.isSubTileBlocked(sx, sy)) {
|
||||||
|
unit.x = sx
|
||||||
|
unit.y = sy
|
||||||
|
if (unit.emitterKind === 'frozenorb') {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / 16
|
||||||
|
this.spawnMissileByKey(
|
||||||
|
'frozenorbbolt',
|
||||||
|
unit.x,
|
||||||
|
unit.y,
|
||||||
|
unit.x + Math.cos(angle) * 16,
|
||||||
|
unit.y + Math.sin(angle) * 16,
|
||||||
|
{
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.triggerMissileImpact(unit, unit.x, unit.y, true)
|
||||||
|
this._units.delete(unit.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hostile unit collision check along the step segment (only for ground-level flying projectiles, excluding Frozen Orb carrier)
|
||||||
|
if (isMoving && (unit.altitude ?? 0) <= 0 && unit.emitterKind !== 'frozenorb') {
|
||||||
|
const hitRadius = 1.75
|
||||||
|
const segDx = unit.x - prevX
|
||||||
|
const segDy = unit.y - prevY
|
||||||
|
const segLenSq = segDx * segDx + segDy * segDy
|
||||||
|
const distToSeg = (tx: number, ty: number): number => {
|
||||||
|
if (segLenSq <= 1e-6) return Math.hypot(tx - unit.x, ty - unit.y)
|
||||||
|
const t = Math.max(0, Math.min(1, ((tx - prevX) * segDx + (ty - prevY) * segDy) / segLenSq))
|
||||||
|
return Math.hypot(tx - (prevX + segDx * t), ty - (prevY + segDy * t))
|
||||||
|
}
|
||||||
|
if (unit.ownerType === 'monster') {
|
||||||
|
for (const target of this._units.values()) {
|
||||||
|
if (target.unitType !== 'player' && !(this._merc && target.id === this._merc.mercId)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (target.dead) continue
|
||||||
|
if (unit.hitUnitIds?.has(target.id)) continue
|
||||||
|
if (distToSeg(target.x, target.y) <= hitRadius) {
|
||||||
|
unit.hitUnitIds?.add(target.id)
|
||||||
|
this.triggerMissileImpact(unit, target.x, target.y, false)
|
||||||
|
if (!unit.pierce) {
|
||||||
|
this._units.delete(unit.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const target of this._monsters.values()) {
|
||||||
|
if (target.dead) continue
|
||||||
|
if (this._merc && target.id === this._merc.mercId) continue
|
||||||
|
if (unit.hitUnitIds?.has(target.id)) continue
|
||||||
|
if (distToSeg(target.x, target.y) <= hitRadius) {
|
||||||
|
unit.hitUnitIds?.add(target.id)
|
||||||
|
this.triggerMissileImpact(unit, target.x, target.y, false)
|
||||||
|
if (!unit.pierce) {
|
||||||
|
this._units.delete(unit.key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lifetime / range expiry check
|
||||||
|
if (unit.ageTicks >= (unit.maxTicks ?? 1)) {
|
||||||
|
if (unit.emitterKind === 'frozenorb') {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const angle = (2 * Math.PI * i) / 16
|
||||||
|
this.spawnMissileByKey(
|
||||||
|
'frozenorbbolt',
|
||||||
|
unit.x,
|
||||||
|
unit.y,
|
||||||
|
unit.x + Math.cos(angle) * 16,
|
||||||
|
unit.y + Math.sin(angle) * 16,
|
||||||
|
{
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
this.triggerMissileImpact(unit, unit.x, unit.y, false)
|
||||||
|
} else if (unit.missileKey === 'meteor') {
|
||||||
|
this.triggerMissileImpact(unit, unit.x, unit.y, false)
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
this.spawnMissileByKey('meteorfire', unit.x + i, unit.y, unit.x + i, unit.y, {
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (unit.missileKey === 'foa') {
|
||||||
|
for (const m of this._monsters.values()) {
|
||||||
|
if (m.dead || (this._merc && m.id === this._merc.mercId)) continue
|
||||||
|
if (Math.hypot(m.x - unit.x, m.y - unit.y) <= 12) {
|
||||||
|
this.spawnMissileByKey('fistoftheheavensbolt', unit.x, unit.y, m.x, m.y, {
|
||||||
|
ownerType: unit.ownerType,
|
||||||
|
ownerId: unit.ownerId,
|
||||||
|
sourceSkillId: unit.sourceSkillId,
|
||||||
|
skillLevel: unit.skillLevel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (unit.alwaysExplode && unit.explosionMissile) {
|
||||||
|
this.triggerMissileImpact(unit, unit.x, unit.y, false)
|
||||||
|
}
|
||||||
|
this._units.delete(unit.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(dtMs = 40, nowMs?: number): void {
|
||||||
this.frameCount++
|
this.frameCount++
|
||||||
const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10)
|
const safeDt = Math.max(0, dtMs)
|
||||||
for (const unit of this._units.values()) {
|
this._simTimeMs += safeDt
|
||||||
|
const step = Math.max(1, (safeDt / 1000) * 10)
|
||||||
|
const ticksToStep = Math.max(1, Math.round(safeDt / 40))
|
||||||
|
|
||||||
|
const unitsSnapshot = Array.from(this._units.values())
|
||||||
|
for (const unit of unitsSnapshot) {
|
||||||
|
if (!this._units.has(unit.key)) continue
|
||||||
|
|
||||||
|
// 1. Step missile units
|
||||||
|
if (unit.unitType === 'missile') {
|
||||||
|
for (let t = 0; t < ticksToStep; t++) {
|
||||||
|
if (!this._units.has(unit.key)) break
|
||||||
|
this.stepMissileUnit(unit)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Step unit overlays (one-shot cast overlays & state overlays)
|
||||||
|
if (unit.overlays.length > 0) {
|
||||||
|
const remainingOverlays: ClientUnitOverlay[] = []
|
||||||
|
for (const ov of unit.overlays) {
|
||||||
|
ov.ageTicks += ticksToStep
|
||||||
|
const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16)
|
||||||
|
if (ov.loop) {
|
||||||
|
ov.frame = rawFrame % Math.max(1, ov.totalFrames)
|
||||||
|
remainingOverlays.push(ov)
|
||||||
|
} else if (ov.ageTicks < ov.maxTicks) {
|
||||||
|
ov.frame = Math.min(Math.max(0, ov.totalFrames - 1), rawFrame)
|
||||||
|
remainingOverlays.push(ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unit.overlays = remainingOverlays
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Expire castState when cast window completes
|
||||||
|
if (unit.castState) {
|
||||||
|
const simStart = this._castSimStartMs.get(unit.key) ?? this._simTimeMs
|
||||||
|
const simElapsed = this._simTimeMs - simStart
|
||||||
|
const wallExpired = nowMs !== undefined && nowMs >= unit.castState.untilMs
|
||||||
|
if (simElapsed >= 400 || wallExpired) {
|
||||||
|
unit.castState = undefined
|
||||||
|
this._castSimStartMs.delete(unit.key)
|
||||||
|
if (!unit.dead) {
|
||||||
|
unit.mode = 1 // return to NU (neutral)
|
||||||
|
unit.targetX = unit.x
|
||||||
|
unit.targetY = unit.y
|
||||||
|
}
|
||||||
|
if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) {
|
||||||
|
this._self.castState = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Interpolate moving non-casting units
|
||||||
if (unit.dead) continue
|
if (unit.dead) continue
|
||||||
if (unit.targetX === undefined || unit.targetY === undefined) continue
|
if (unit.targetX === undefined || unit.targetY === undefined) continue
|
||||||
const dx = unit.targetX - unit.x
|
const dx = unit.targetX - unit.x
|
||||||
|
|
@ -768,6 +1609,33 @@ export class ClientWorld {
|
||||||
ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 0,
|
ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 0,
|
||||||
)
|
)
|
||||||
const skillId = Number(ev.skillId ?? ev.skill ?? ev.attackType ?? 0)
|
const skillId = Number(ev.skillId ?? ev.skill ?? ev.attackType ?? 0)
|
||||||
|
const level = Number(ev.level ?? 1) || 1
|
||||||
|
|
||||||
|
// 0x73 CreateMissile: caster.unitType === 3 ('missile')
|
||||||
|
if (unitTypeFromId(casterType) === 'missile') {
|
||||||
|
const missileClassId = skillId
|
||||||
|
const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId] ?? 'arrow'
|
||||||
|
const ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player'
|
||||||
|
const ownerId = ev.owner?.id ?? 0
|
||||||
|
const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined
|
||||||
|
const startX = ev.at ? Number(ev.at.x) : (ownerUnit?.x ?? this._self.x)
|
||||||
|
const startY = ev.at ? Number(ev.at.y) : (ownerUnit?.y ?? this._self.y)
|
||||||
|
let targetX = startX
|
||||||
|
let targetY = startY
|
||||||
|
if (ev.target && typeof ev.target === 'object' && 'x' in ev.target) {
|
||||||
|
targetX = Number(ev.target.x ?? startX)
|
||||||
|
targetY = Number(ev.target.y ?? startY)
|
||||||
|
}
|
||||||
|
this.spawnMissileByKey(missileKey, startX, startY, targetX, targetY, {
|
||||||
|
id: casterId > 0 ? casterId : undefined,
|
||||||
|
classId: missileClassId,
|
||||||
|
ownerType,
|
||||||
|
ownerId,
|
||||||
|
skillLevel: level,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
let unit = this.getUnit(casterType, casterId)
|
let unit = this.getUnit(casterType, casterId)
|
||||||
if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) {
|
if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) {
|
||||||
unit = this.ensureUnit('player', casterId || this.selfId || 1)
|
unit = this.ensureUnit('player', casterId || this.selfId || 1)
|
||||||
|
|
@ -798,7 +1666,19 @@ export class ClientWorld {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.applySkillCastToUnit(unit, skillId, targetX, targetY)
|
|
||||||
|
const isSelfEcho =
|
||||||
|
unit.unitType === 'player' &&
|
||||||
|
(unit.id === this.selfId || this.selfId === 0) &&
|
||||||
|
skillId === this._lastLocalCastSkillId &&
|
||||||
|
Date.now() - this._lastLocalCastAtMs >= 0 &&
|
||||||
|
Date.now() - this._lastLocalCastAtMs <= 250
|
||||||
|
if (isSelfEcho) {
|
||||||
|
this._lastLocalCastSkillId = -1
|
||||||
|
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false)
|
||||||
|
} else {
|
||||||
|
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1352,6 +2232,14 @@ export class ClientWorld {
|
||||||
this._chatLog.length = 0
|
this._chatLog.length = 0
|
||||||
this._soundQueue.length = 0
|
this._soundQueue.length = 0
|
||||||
this._portals.clear()
|
this._portals.clear()
|
||||||
|
this._nextMissileId = 0x70000000
|
||||||
|
this._nextOverlayId = 1
|
||||||
|
this._lastLocalCastSkillId = -1
|
||||||
|
this._lastLocalCastAtMs = -1000
|
||||||
|
this._simTimeMs = 0
|
||||||
|
this._castSimStartMs.clear()
|
||||||
|
this._notedCommands = new WeakSet<object>()
|
||||||
|
this._blockedSubTiles.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,8 @@ export interface MissileTxtData {
|
||||||
readonly trans?: number | undefined
|
readonly trans?: number | undefined
|
||||||
readonly subLoop?: number | undefined
|
readonly subLoop?: number | undefined
|
||||||
readonly subStop?: number | undefined
|
readonly subStop?: number | undefined
|
||||||
|
readonly alwaysExplode?: number | boolean | undefined
|
||||||
|
readonly pierce?: number | boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
||||||
|
|
@ -1518,8 +1520,248 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
trans: 1,
|
trans: 1,
|
||||||
distancePx: 750,
|
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).
|
* Safely parse an RGB color channel (0..255) with fallback (default 255).
|
||||||
* Preserves authentic 0 values while mapping empty/unspecified cells to fallback.
|
* 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,
|
xOffset: 0,
|
||||||
yOffset: 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',
|
'bone wall': 'bonecast',
|
||||||
boneprison: 'bonecast',
|
boneprison: 'bonecast',
|
||||||
'bone prison': '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({
|
const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly<Record<number, string>> = Object.freeze({
|
||||||
|
66: 'cursecast', // Amplify Damage
|
||||||
|
67: 'bonecast', // Teeth
|
||||||
68: 'bonearmor_cast', // Bone Armor
|
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
|
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
|
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 (
|
return (
|
||||||
SORCERESS_SKILL_CAST_OVERLAYS[skillId] ??
|
SORCERESS_SKILL_CAST_OVERLAYS[skillId] ??
|
||||||
NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ??
|
NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ??
|
||||||
|
OTHER_CLASS_SKILL_CAST_OVERLAYS[skillId] ??
|
||||||
BATCH1_SKILLS[skillId]?.castOverlay
|
BATCH1_SKILLS[skillId]?.castOverlay
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -4109,6 +4683,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine
|
||||||
return (
|
return (
|
||||||
SORCERESS_SKILL_CAST_OVERLAYS[num] ??
|
SORCERESS_SKILL_CAST_OVERLAYS[num] ??
|
||||||
NECROMANCER_SKILL_CAST_OVERLAYS[num] ??
|
NECROMANCER_SKILL_CAST_OVERLAYS[num] ??
|
||||||
|
OTHER_CLASS_SKILL_CAST_OVERLAYS[num] ??
|
||||||
BATCH1_SKILLS[num]?.castOverlay
|
BATCH1_SKILLS[num]?.castOverlay
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,17 @@ export function decode0x73CreateMissile(pkt: Uint8Array): ServerEvent {
|
||||||
const y = r.u16LE()
|
const y = r.u16LE()
|
||||||
const targetX = r.u16LE()
|
const targetX = r.u16LE()
|
||||||
const targetY = 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 {
|
return {
|
||||||
type: 'SkillCast',
|
type: 'SkillCast',
|
||||||
caster: { unitType: 3, id: missileClassId },
|
caster: { unitType: 3, id: missileClassId },
|
||||||
skill: missileClassId,
|
skill: missileClassId,
|
||||||
level: 1,
|
level: level || 1,
|
||||||
|
at: { x, y },
|
||||||
target: { x: targetX || x, y: targetY || y },
|
target: { x: targetX || x, y: targetY || y },
|
||||||
|
owner: { unitType: ownerType, id: ownerId },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ export type ServerEvent =
|
||||||
readonly skillId?: number | undefined
|
readonly skillId?: number | undefined
|
||||||
readonly level: number
|
readonly level: number
|
||||||
readonly target: UnitRef | Pt
|
readonly target: UnitRef | Pt
|
||||||
|
readonly at?: Pt | undefined
|
||||||
|
readonly owner?: UnitRef | undefined
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly type: 'StatSet'
|
readonly type: 'StatSet'
|
||||||
|
|
|
||||||
|
|
@ -678,4 +678,290 @@ describe('ClientWorld authoritative state mirror', () => {
|
||||||
expect(world.containers.tradeGrid).toHaveLength(0)
|
expect(world.containers.tradeGrid).toHaveLength(0)
|
||||||
expect(world.containers.partnerTradeGrid).toHaveLength(0)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1031,5 +1031,86 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => {
|
||||||
session.destroy()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue