diff --git a/src/client/scene/world-renderer.ts b/src/client/scene/world-renderer.ts index e7680d2..c88d4e7 100644 --- a/src/client/scene/world-renderer.ts +++ b/src/client/scene/world-renderer.ts @@ -1,3 +1,4 @@ +import { CANONICAL_113C_OVERLAYS } from '../../common/skills/missiles-data.ts' import { dir64ToClipDirection } from '../../common/units/direction.ts' import { ADDITIVE_MONSTER_TOKENS, type LoadedMonsterArt } from '../render/monster-art.ts' import { npcDisplayName } from '../../common/units/npc-table.ts' @@ -734,6 +735,9 @@ function drawSceneMissileDrawable( ctx: SceneSourceRenderContext, nowMs: number, ): void { + if (missile.celFile.toLowerCase() === 'sqr') { + return + } const art = ctx.missileArtMap?.get(missile.missileKey) ?? ctx.missileArtMap?.get(missile.celFile.toLowerCase()) @@ -742,17 +746,21 @@ function drawSceneMissileDrawable( const dir = ((missile.direction % dirCount) + dirCount) % dirCount const group = art.frames[dir] ?? art.frames[0] 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] if (frame !== undefined) { + const altitude = missile.altitude ?? 0 const drawX = frame.anchorX !== undefined ? missile.worldX + frame.anchorX : missile.worldX - frame.width / 2 const drawY = - frame.anchorY !== undefined + (frame.anchorY !== undefined ? missile.worldY + frame.anchorY - : missile.worldY - frame.height / 2 + : missile.worldY - frame.height / 2) - altitude renderer.draw(frame, drawX, drawY, { atlas: art.handle, blendMode: missile.blendMode, @@ -773,13 +781,25 @@ function drawSceneOverlayDrawable( ctx: SceneSourceRenderContext, nowMs: number, ): void { + const lowerToken = overlay.token.toLowerCase() + const celFile = CANONICAL_113C_OVERLAYS[lowerToken]?.filename + const lowerCel = celFile?.toLowerCase() const art = ctx.overlayArtMap?.get(overlay.token) ?? - ctx.overlayArtMap?.get(overlay.token.toLowerCase()) + ctx.overlayArtMap?.get(lowerToken) ?? + (celFile ? ctx.overlayArtMap?.get(celFile) : undefined) ?? + (lowerCel ? ctx.overlayArtMap?.get(lowerCel) : undefined) ?? + ctx.missileArtMap?.get(overlay.token) ?? + ctx.missileArtMap?.get(lowerToken) ?? + (celFile ? ctx.missileArtMap?.get(celFile) : undefined) ?? + (lowerCel ? ctx.missileArtMap?.get(lowerCel) : undefined) if (art !== undefined) { const group = art.frames[0] 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] if (frame !== undefined) { const drawX = diff --git a/src/client/session/online-session.ts b/src/client/session/online-session.ts index 6b702ca..ce9d6e6 100644 --- a/src/client/session/online-session.ts +++ b/src/client/session/online-session.ts @@ -298,6 +298,40 @@ export class OnlineSession { this.predictor = options.predictor ?? new LocalMovementPredictor() this.interpolator = options.interpolator ?? new RemoteEntityInterpolator() + + this.world.setCollisionChecker((subX, subY) => { + const predSnap = this.predictor.getSnapshot() + const px = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.x : this.world.self.x + const py = predSnap.x !== 0 || predSnap.y !== 0 ? predSnap.y : this.world.self.y + const activeView = + this.levelView ?? + (this.mapService && this.world.areaId > 0 && (px !== 0 || py !== 0) + ? this.mapService.findLevelViewAtWorldSubTile(px, py, [ + this.world.areaId, + ...this.world.getRevealedAreaIds(), + ]) + : null) ?? + this.mapService?.getActiveLevelView() ?? + this.automapView.getLevelView() + if (!activeView) return false + if (typeof (activeView as any).isWalkableWorldSubTile === 'function') { + return !(activeView as any).isWalkableWorldSubTile(subX, subY) + } + const localX = Math.floor(subX - (activeView.subOriginX ?? 0)) + const localY = Math.floor(subY - (activeView.subOriginY ?? 0)) + if ( + localX < 0 || + localY < 0 || + localX >= activeView.subWidth || + localY >= activeView.subHeight + ) { + return false + } + const idx = localY * activeView.subWidth + localX + if (activeView.blocked && activeView.blocked[idx] === 1) return true + if (activeView.walkable) return activeView.walkable[idx] === 0 + return false + }) this.rateLimiter = options.rateLimiter ?? new OutboundRateLimiter() this.soundService = options.soundService ?? @@ -955,12 +989,65 @@ export class OnlineSession { : (cmd as ClientCommand) const res = this.rateLimiter.tryAcquire(normalized, nowMs) if (res.allowed) { + this.world.noteOutgoingCommand(normalized) this.forwardCommandToAdapter(normalized) return true } return false } + /** + * Alias for `dispatchCommand` used by input/HUD callers. + */ + sendCommand(cmd: ClientCommand | Record, nowMs = performance.now()): boolean { + return this.dispatchCommand(cmd, nowMs) + } + + /** + * Casts the active left or right skill at world sub-tile `(x, y)`. + */ + useSkillAt( + x: number, + y: number, + hand: 'left' | 'right' = 'right', + shift = false, + ): boolean { + const cmd: ClientCommand = { + type: 'SkillAt', + hand, + at: { x: Math.round(x), y: Math.round(y) }, + shift, + } + this.world.noteOutgoingCommand(cmd) + return this.dispatchCommand(cmd) + } + + /** + * Casts the active left or right skill on target unit `(unitKind, unitId)`. + */ + useSkillOn( + unitKind: UnitType, + unitId: number, + hand: 'left' | 'right' = 'right', + shift = false, + ): boolean { + const cmd: ClientCommand = { + type: 'SkillOn', + hand, + target: { unitType: unitKind, id: unitId }, + shift, + } + this.world.noteOutgoingCommand(cmd) + return this.dispatchCommand(cmd) + } + + /** + * Attacks target unit `(unitKind, unitId)` using the active left-hand skill. + */ + attackUnit(unitKind: UnitType, unitId: number, shift = false): boolean { + return this.useSkillOn(unitKind, unitId, 'left', shift) + } + private forwardCommandToAdapter(cmd: ClientCommand): void { this.outboundCommandsLog.push(cmd) if (this.outboundCommandsLog.length > 512) { @@ -1305,6 +1392,8 @@ export class OnlineSession { unit.direction = state.dir64 } } + + this.world.tick(dtMs, nowMs) } /** @@ -1353,6 +1442,7 @@ export class OnlineSession { isLocalPlayer: true, isCorpse: isSelfDead, ...(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) { continue } + if (u.unitType === 'missile') { + out.push({ + id: u.id, + kind: 'missile', + classId: u.classId, + ...(u.name !== undefined ? { name: u.name } : {}), + ...(u.missileKey !== undefined ? { missileKey: u.missileKey } : {}), + x: u.x, + y: u.y, + ...(u.targetX !== undefined ? { targetX: u.targetX } : {}), + ...(u.targetY !== undefined ? { targetY: u.targetY } : {}), + ...(u.vx !== undefined ? { vx: u.vx } : {}), + ...(u.vy !== undefined ? { vy: u.vy } : {}), + ...(u.frame !== undefined ? { frame: u.frame } : {}), + ...(u.totalFrames !== undefined ? { totalFrames: u.totalFrames } : {}), + ...(u.loopAnim !== undefined ? { loopAnim: u.loopAnim } : {}), + ...(u.altitude !== undefined ? { altitude: u.altitude } : {}), + mode: u.mode, + direction: u.direction, + hpRatio: 1, + isCorpse: false, + }) + continue + } const interp = this.interpolator.getEntityState( u.unitType === 'player' ? 0 @@ -1378,11 +1492,9 @@ export class OnlineSession { ? 1 : u.unitType === 'object' ? 2 - : u.unitType === 'missile' - ? 3 - : u.unitType === 'item' - ? 4 - : 5, + : u.unitType === 'item' + ? 4 + : 5, u.id, ) const groundItem = u.unitType === 'item' ? this.world.containers.ground.get(u.id) : undefined @@ -1543,6 +1655,7 @@ export class OnlineSession { isSuperUnique: u.isSuperUnique, states: Array.from(u.states.list()), ...(castState !== undefined ? { castState } : {}), + ...(u.overlays.length > 0 ? { overlays: u.overlays } : {}), ...(u.unitType === 'player' ? { isPartyMember } : {}), }) } diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index 0650ee8..91ee19b 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -1,6 +1,12 @@ -import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts' +import { + CANONICAL_113C_MISSILES, + CANONICAL_113C_OVERLAYS, + MISSILE_CLASS_ID_TO_KEY, + getMissileTxtData, + getSkillCastOverlay, +} from '../../common/skills/missiles-data.ts' import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' -import { assertDir64, dir64FromPoints } from '../../common/units/direction.ts' +import { assertDir64, dir64FromPoints, dir64ToClipDirection } from '../../common/units/direction.ts' import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts' import type { PointLightInput } from '../scene/lighting-presets.ts' import type { UnitCastState } from '../world/client-unit.ts' @@ -16,8 +22,11 @@ export interface UnitOverlayState { readonly token?: string | undefined readonly frame?: number | undefined readonly totalFrames?: number | undefined - readonly blendMode?: DrawableBlendMode | undefined + readonly blendMode?: DrawableBlendMode | 0 | 1 | 2 | 3 | undefined readonly yOffset?: number | undefined + readonly loop?: boolean | undefined + readonly lightRadius?: number | undefined + readonly lightColor?: readonly [number, number, number] | undefined } /** @@ -38,11 +47,17 @@ export interface RenderableUnitInput { readonly mode?: number | undefined /** * Facing. Players, monsters, objects and items: a dir64 (`common/units/direction.ts`), kept - * by the motion layer; 0 (south) when absent. Missiles: the 16-direction index used when no + * by the motion layer; 0 (south) when absent. Missiles: the 16-direction index or dir64 used when no * target is given. */ readonly direction?: number | undefined readonly frame?: number | undefined + readonly totalFrames?: number | undefined + readonly loopAnim?: boolean | undefined + readonly missileKey?: string | undefined + readonly vx?: number | undefined + readonly vy?: number | undefined + readonly altitude?: number | undefined readonly lifePct?: number | undefined readonly name?: string | undefined readonly code?: string | undefined @@ -121,6 +136,11 @@ export interface SceneMissileDrawable { readonly celFile: string readonly direction: number readonly frame: number + readonly totalFrames?: number | undefined + readonly loopAnim?: boolean | undefined + readonly vx?: number | undefined + readonly vy?: number | undefined + readonly altitude?: number | undefined readonly subX: number readonly subY: number readonly worldX: number @@ -139,6 +159,7 @@ export interface SceneOverlayDrawable { readonly token: string readonly frame: number readonly totalFrames: number + readonly loop?: boolean | undefined readonly worldX: number readonly worldY: number readonly sortY: number @@ -208,7 +229,10 @@ export function resolveUnitToken(unit: RenderableUnitInput): string { return token } if (unit.kind === 'missile') { - const key = MISSILE_ID_TO_KEY[unit.classId] + const key = + unit.missileKey && CANONICAL_113C_MISSILES[unit.missileKey.toLowerCase()] + ? unit.missileKey.toLowerCase() + : (MISSILE_ID_TO_KEY[unit.classId] ?? MISSILE_CLASS_ID_TO_KEY[unit.classId]) // Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile. return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}` } @@ -237,36 +261,40 @@ export function resolveMissileMetadata( classId: number, explicitToken?: string, ): { readonly key: string; readonly entry: MissileTxtEntry } | null { + const tokenKey = + explicitToken && explicitToken.trim() !== '' ? explicitToken.trim().toLowerCase() : undefined const key = - explicitToken && explicitToken.trim() !== '' - ? explicitToken.trim().toLowerCase() - : MISSILE_ID_TO_KEY[classId] - if (!key) return null + tokenKey && CANONICAL_113C_MISSILES[tokenKey] + ? tokenKey + : (MISSILE_ID_TO_KEY[classId] ?? MISSILE_CLASS_ID_TO_KEY[classId]) + if (!key || !CANONICAL_113C_MISSILES[key]) return null return { key, entry: getMissileTxtData(key), } } -function computeDirection8Or16( - fromX: number, - fromY: number, - toX: number | undefined, - toY: number | undefined, - fallbackDir = 0, - numDirs = 16, +function computeMissileClipDirection( + unit: RenderableUnitInput, + numDirs: number, ): number { - if (toX === undefined || toY === undefined) { - return ((fallbackDir % numDirs) + numDirs) % numDirs + const validDirs = [1, 2, 4, 8, 16, 32, 64].includes(numDirs) ? numDirs : 16 + if (validDirs === 1) return 0 + const fallbackDir64 = Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64 + ? (unit.direction as number) + : 0 + if (unit.vx !== undefined && unit.vy !== undefined && (Math.abs(unit.vx) > 1e-5 || Math.abs(unit.vy) > 1e-5)) { + const dir64 = dir64FromPoints(0, 0, unit.vx, unit.vy, fallbackDir64) + return dir64ToClipDirection(dir64, validDirs) } - const dx = toX - fromX - const dy = toY - fromY - if (Math.abs(dx) < 1e-4 && Math.abs(dy) < 1e-4) { - return ((fallbackDir % numDirs) + numDirs) % numDirs + if (unit.targetX !== undefined && unit.targetY !== undefined) { + const dir64 = dir64FromPoints(unit.x, unit.y, unit.targetX, unit.targetY, fallbackDir64) + return dir64ToClipDirection(dir64, validDirs) } - const angle = Math.atan2(dy, dx) - const normalized = ((angle + Math.PI * 2) % (Math.PI * 2)) / (Math.PI * 2) - return Math.floor(normalized * numDirs) % numDirs + if (Number.isInteger(unit.direction) && (unit.direction ?? 0) >= 0 && (unit.direction ?? 0) < 64) { + return dir64ToClipDirection(unit.direction as number, validDirs) + } + return (((unit.direction ?? 0) % validDirs) + validDirs) % validDirs } /** @@ -345,19 +373,17 @@ export function buildSceneDrawables( const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY) if (unit.kind === 'missile') { - const resolved = resolveMissileMetadata(unit.classId, unit.token) + const resolved = resolveMissileMetadata(unit.classId, unit.missileKey ?? unit.token ?? unit.name) if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art). const { key, entry } = resolved const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal' - const dir = computeDirection8Or16( - unit.x, - unit.y, - unit.targetX, - unit.targetY, - unit.direction ?? 0, - 16, - ) - const frame = unit.frame ?? (tick % 8) + const dir = computeMissileClipDirection(unit, entry.numDirections || 16) + const totalFrames = Math.max(1, unit.totalFrames ?? entry.animLen ?? 8) + const loopAnim = unit.loopAnim !== undefined ? unit.loopAnim : entry.loopAnim !== 0 + const rawFrame = unit.frame ?? tick + const frame = loopAnim + ? ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames + : Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame))) const lightUnits = entry.light > 0 ? entry.light : entry.trans === 1 ? 6 : 0 const isCold = entry.blue > entry.red const lightColor: readonly [number, number, number] = [ @@ -374,6 +400,11 @@ export function buildSceneDrawables( celFile: entry.celFile, direction: dir, frame, + totalFrames, + loopAnim, + ...(unit.vx !== undefined ? { vx: unit.vx } : {}), + ...(unit.vy !== undefined ? { vy: unit.vy } : {}), + ...(unit.altitude !== undefined ? { altitude: unit.altitude } : {}), subX: unit.x, subY: unit.y, worldX, @@ -404,11 +435,31 @@ export function buildSceneDrawables( const unitOverlays: SceneOverlayDrawable[] = [] if (unit.overlays) { for (const ov of unit.overlays) { + const ovTxt = ov.token ? CANONICAL_113C_OVERLAYS[ov.token.trim().toLowerCase()] : undefined const meta = OVERLAY_METADATA[ov.overlayId] - const totalFrames = ov.totalFrames ?? meta?.totalFrames ?? 16 - const frame = ov.frame ?? (tick % totalFrames) - const yOffset = ov.yOffset ?? meta?.yOffset ?? 0 - const blendMode = ov.blendMode ?? meta?.blendMode ?? 'additive' + const totalFrames = Math.max(1, ov.totalFrames ?? ovTxt?.frames ?? meta?.totalFrames ?? 16) + const isOneShot = ov.loop === false || (ovTxt !== undefined && ov.loop !== true) + const rawFrame = ov.frame ?? tick + const frame = isOneShot + ? Math.min(totalFrames - 1, Math.max(0, Math.floor(rawFrame))) + : ((Math.floor(rawFrame) % totalFrames) + totalFrames) % totalFrames + const yOffset = + ov.yOffset ?? + (ovTxt?.yOffset !== undefined ? -ovTxt.yOffset : undefined) ?? + meta?.yOffset ?? + 0 + const blendMode: DrawableBlendMode = + typeof ov.blendMode === 'string' + ? ov.blendMode + : typeof ov.blendMode === 'number' + ? ov.blendMode === 0 + ? 'normal' + : 'additive' + : ovTxt + ? ovTxt.trans === 0 + ? 'normal' + : 'additive' + : (meta?.blendMode ?? 'additive') const ovDrawable: SceneOverlayDrawable = { drawableKind: 'overlay', ownerUnitId: unit.id, @@ -416,6 +467,7 @@ export function buildSceneDrawables( token: ov.token ?? meta?.token ?? `OVL_${ov.overlayId}`, frame, totalFrames, + loop: !isOneShot, worldX, worldY: worldY + yOffset, sortY: worldY + 1, @@ -424,15 +476,23 @@ export function buildSceneDrawables( unitOverlays.push(ovDrawable) overlayDrawables.push(ovDrawable) - if (meta?.lightRadius && meta.lightColor) { + const lightRadius = + ov.lightRadius ?? + (ovTxt && ovTxt.radius > 0 ? Math.max(12, ovTxt.radius) : meta?.lightRadius) + const lightColor: readonly [number, number, number] | undefined = + ov.lightColor ?? + (ovTxt && (ovTxt.red > 0 || ovTxt.green > 0 || ovTxt.blue > 0) + ? [ovTxt.red, ovTxt.green, ovTxt.blue] + : meta?.lightColor) + if (lightRadius && lightColor) { pointLights.push({ subTileX: Math.round(unit.x), subTileY: Math.round(unit.y), - radius: meta.lightRadius, - intensity: 190, - r: meta.lightColor[0], - g: meta.lightColor[1], - b: meta.lightColor[2], + radius: lightRadius, + intensity: 210, + r: lightColor[0], + g: lightColor[1], + b: lightColor[2], }) } } @@ -457,6 +517,7 @@ export function buildSceneDrawables( token: meta.token, frame: tick % meta.totalFrames, totalFrames: meta.totalFrames, + loop: true, worldX, worldY: worldY + meta.yOffset, sortY: worldY + 1, @@ -496,16 +557,55 @@ export function buildSceneDrawables( ? 'SC' : undefined) - if (activeCastState && effectiveMode === 10) { - pointLights.push({ - subTileX: Math.round(unit.x), - subTileY: Math.round(unit.y), - radius: 10, - intensity: 210, - r: 255, - g: 195, - b: 110, - }) + if (activeCastState) { + const castOverlayKey = + activeCastState.castOverlayToken ?? getSkillCastOverlay(activeCastState.skillId) + if ( + castOverlayKey && + !unitOverlays.some((o) => o.token.toLowerCase() === castOverlayKey.toLowerCase()) + ) { + const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] + if (ovTxt) { + const totalFrames = Math.max(1, ovTxt.frames) + const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick))) + const ovDrawable: SceneOverlayDrawable = { + drawableKind: 'overlay', + ownerUnitId: unit.id, + overlayId: activeCastState.skillId || 1000, + token: castOverlayKey, + frame, + totalFrames, + loop: false, + worldX, + worldY: worldY - (ovTxt.yOffset ?? 0), + sortY: worldY + 1, + blendMode: ovTxt.trans === 0 ? 'normal' : 'additive', + } + unitOverlays.push(ovDrawable) + overlayDrawables.push(ovDrawable) + if (ovTxt.radius > 0) { + pointLights.push({ + subTileX: Math.round(unit.x), + subTileY: Math.round(unit.y), + radius: Math.max(12, ovTxt.radius), + intensity: 215, + r: ovTxt.red, + g: ovTxt.green, + b: ovTxt.blue, + }) + } + } + } else if (effectiveMode === 10) { + pointLights.push({ + subTileX: Math.round(unit.x), + subTileY: Math.round(unit.y), + radius: 10, + intensity: 210, + r: 255, + g: 195, + b: 110, + }) + } } const dir = unitFacingDir64(unit, effectiveTargetX, effectiveTargetY) diff --git a/src/client/world/client-unit.ts b/src/client/world/client-unit.ts index 26e927c..c2ca9ed 100644 --- a/src/client/world/client-unit.ts +++ b/src/client/world/client-unit.ts @@ -17,6 +17,22 @@ import { import { StatList } from './stat-list.ts' import { UnitStateSet } from './states.ts' +export interface ClientUnitOverlay { + readonly overlayId: number + readonly token: string + frame: number + readonly totalFrames: number + readonly animRate: number + readonly loop: boolean + readonly blendMode: 0 | 1 | 2 | 3 + readonly yOffset: number + readonly startedAtMs: number + ageTicks: number + readonly maxTicks: number + readonly lightRadius: number + readonly lightColor: readonly [number, number, number] +} + export interface UnitCastState { readonly skillId: number readonly targetX: number @@ -24,6 +40,7 @@ export interface UnitCastState { readonly mode: number readonly startedAtMs: number readonly untilMs: number + readonly castOverlayToken?: string | undefined } export function makeUnitKey(unitType: UnitType, id: number): string { @@ -62,6 +79,35 @@ export class ClientUnit { overheadText?: string | undefined itemData?: ItemData | undefined castState?: UnitCastState | undefined + lastSkillId?: number | undefined + overlays: ClientUnitOverlay[] = [] + // Missile simulation fields (populated when unitType === 'missile') + missileKey?: string | undefined + vx?: number | undefined + vy?: number | undefined + ageTicks?: number | undefined + maxTicks?: number | undefined + frame?: number | undefined + totalFrames?: number | undefined + animSpeed?: number | undefined + loopAnim?: boolean | undefined + numDirections?: number | undefined + explosionMissile?: string | undefined + alwaysExplode?: boolean | undefined + pierce?: boolean | undefined + hitUnitIds?: Set | undefined + altitude?: number | undefined + originX?: number | undefined + originY?: number | undefined + spiralAngle?: number | undefined + spiralRadius?: number | undefined + jitterIndex?: number | undefined + emitterKind?: 'frozenorb' | 'meteor_controller' | 'blizzard_controller' | undefined + subMissileIdx?: number | undefined + startedAtMs?: number | undefined + startedAtTick?: number | undefined + sourceSkillId?: number | undefined + skillLevel?: number | undefined readonly stats = new StatList() readonly states = new UnitStateSet() diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index e1e2a12..2c48c92 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -16,10 +16,23 @@ import { type SkillLevel, type UnitRef, type UnitType, + type UnitTypeName, } from '../../netproto/index.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 { 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 { 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`). */ 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> = + 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> = Object.freeze({ + 44: 'frostnova', + 48: 'nova', + 92: 'poisonnova', +}) + +/** Necromancer curse target overlays (1.13c Skills.txt / Overlay.txt). */ +const CURSE_TARGET_OVERLAYS: Readonly> = 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 { act = 0 mapSeed = 0 @@ -195,6 +273,16 @@ export class ClientWorld { private _npcTransactionSeq = 0 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() + private _notedCommands = new WeakSet() + private readonly _blockedSubTiles = new Set() + private _collisionChecker: ((subX: number, subY: number) => boolean) | null = null + get npcInteraction(): NpcInteractionState { return this._npcInteraction } @@ -216,15 +304,435 @@ export class ClientWorld { 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.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() + 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 // 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) const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10 unit.mode = mode + const castOverlayToken = getSkillCastOverlay(skillId) const now = Date.now() const castState: UnitCastState = { skillId, @@ -233,11 +741,24 @@ export class ClientWorld { mode, startedAtMs: now, untilMs: now + 400, + castOverlayToken, } unit.castState = castState + this._castSimStartMs.set(unit.key, this._simTimeMs) if (unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)) { 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). */ noteOutgoingCommand(cmd: ClientCommand): void { + if (cmd && typeof cmd === 'object') { + if (this._notedCommands.has(cmd)) return + this._notedCommands.add(cmd) + } const cur = this._npcInteraction switch (cmd.type) { case 'NpcInit': @@ -316,6 +841,8 @@ export class ClientWorld { const skillId = isRight ? this._self.rightSkill : this._self.leftSkill const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY) @@ -333,6 +860,8 @@ export class ClientWorld { const targetUnit = this.getUnit(targetRef.unitType, targetRef.id) const targetX = targetUnit?.x ?? this._self.x const targetY = targetUnit?.y ?? this._self.y + this._lastLocalCastSkillId = skillId + this._lastLocalCastAtMs = Date.now() if (this.selfId !== 0) { const selfUnit = this.ensureUnit('player', this.selfId) selfUnit.targetUnit = targetRef @@ -430,10 +959,322 @@ export class ClientWorld { 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++ - const step = Math.max(1, (Math.max(0, dtMs) / 1000) * 10) - for (const unit of this._units.values()) { + const safeDt = Math.max(0, dtMs) + 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.targetX === undefined || unit.targetY === undefined) continue const dx = unit.targetX - unit.x @@ -768,6 +1609,33 @@ export class ClientWorld { ev.caster?.id ?? ev.monsterId ?? ev.unitId ?? ev.attackerId ?? 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) if (!unit && unitTypeFromId(casterType) === 'player' && (casterId === this.selfId || this.selfId === 0)) { 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 } @@ -1352,6 +2232,14 @@ export class ClientWorld { this._chatLog.length = 0 this._soundQueue.length = 0 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() + this._blockedSubTiles.clear() } clear(): void { diff --git a/src/common/skills/missiles-data.ts b/src/common/skills/missiles-data.ts index 0a2eb69..4dd0eed 100644 --- a/src/common/skills/missiles-data.ts +++ b/src/common/skills/missiles-data.ts @@ -216,6 +216,8 @@ export interface MissileTxtData { readonly trans?: number | undefined readonly subLoop?: number | undefined readonly subStop?: number | undefined + readonly alwaysExplode?: number | boolean | undefined + readonly pierce?: number | boolean | undefined } /** Canonical 1.13c Ground Truth Missiles.txt defaults. */ @@ -1518,8 +1520,248 @@ export const CANONICAL_113C_MISSILES: Readonly> = trans: 1, distancePx: 750, }, + bolt: { + name: 'bolt', + id: 1, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Spike', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 0, + green: 0, + blue: 0, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + coldarrow: { + name: 'coldarrow', + id: 13, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'ColdArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 3, + red: 112, + green: 160, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + guidedarrow: { + name: 'guidedarrow', + id: 31, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Arrow', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + explodingarrow: { + name: 'explodingarrow', + id: 41, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'FireArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'explodingarrowexp', + alwaysExplode: true, + light: 3, + red: 255, + green: 175, + blue: 55, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + immolationarrow: { + name: 'immolationarrow', + id: 63, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'FireArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'explodingarrowexp', + alwaysExplode: true, + light: 5, + red: 255, + green: 175, + blue: 55, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + immolationfire: { + name: 'immolationfire', + id: 64, + vel: 0, + maxVel: 0, + range: 75, + levRange: 0, + celFile: 'FireSmall', + animLen: 21, + animSpeed: 16, + loopAnim: 1, + numDirections: 1, + explosionMissile: '', + light: 3, + red: 255, + green: 185, + blue: 75, + speedPxPerSec: 0, + trans: 1, + distancePx: 0, + clientAlias: true, + }, + freezingarrow: { + name: 'freezingarrow', + id: 87, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'ColdArrow', + animLen: 5, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: 'freezingarrowexp1', + alwaysExplode: true, + light: 3, + red: 112, + green: 160, + blue: 255, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, + foa: { + name: 'foa', + id: 211, + vel: 0, + maxVel: 0, + range: 25, + levRange: 0, + celFile: 'FistOfTheHeavensDelay', + animLen: 5, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'fistoftheheavensbolt', + alwaysExplode: true, + light: 8, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 0, + trans: 1, + distancePx: 0, + clientAlias: true, + }, + fistoftheheavensbolt: { + name: 'fistoftheheavensbolt', + id: 212, + vel: 24, + maxVel: 24, + range: 25, + levRange: 0, + celFile: 'HolyBolt', + animLen: 6, + animSpeed: 16, + loopAnim: 1, + numDirections: 16, + explosionMissile: '', + light: 3, + red: 255, + green: 255, + blue: 255, + speedPxPerSec: 600, + trans: 1, + distancePx: 600, + clientAlias: true, + }, + strafearrow: { + name: 'strafearrow', + id: 215, + vel: 24, + maxVel: 24, + range: 40, + levRange: 0, + celFile: 'Arrow', + animLen: 1, + animSpeed: 16, + loopAnim: 1, + numDirections: 32, + explosionMissile: '', + light: 0, + red: 0, + green: 0, + blue: 0, + speedPxPerSec: 600, + trans: 0, + distancePx: 960, + clientAlias: true, + }, }) +/** + * Lookup from 1.13c Missiles.txt numeric `Id` to canonical missile key. + */ +export const MISSILE_CLASS_ID_TO_KEY: Readonly> = Object.freeze( + (() => { + const map: Record = {} + for (const [key, data] of Object.entries(CANONICAL_113C_MISSILES)) { + if (!data.clientAlias || map[data.id] === undefined) { + map[data.id] = key + } + } + return map + })(), +) + + /** * Safely parse an RGB color channel (0..255) with fallback (default 255). * Preserves authentic 0 values while mapping empty/unspecified cells to fallback. @@ -3677,6 +3919,262 @@ export const CANONICAL_113C_OVERLAYS: Readonly> = xOffset: 0, yOffset: 0, }, + bonearmor_cast: { + overlay: 'bonearmor_cast', + filename: 'NecromancerSpellCast', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 215, + green: 215, + blue: 235, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + bonecast: { + overlay: 'bonecast', + filename: 'NecromancerSpellCast', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 215, + green: 215, + blue: 235, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + holy_bolt_cast: { + overlay: 'holy_bolt_cast', + filename: 'HolyBoltCast', + frames: 14, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + fist_of_the_heavens_cast: { + overlay: 'fist_of_the_heavens_cast', + filename: 'FistOfTheHeavensCast', + frames: 12, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + conversioncast: { + overlay: 'conversioncast', + filename: 'ConversionCast', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + cast_innersight: { + overlay: 'cast_innersight', + filename: 'CastInnerSight', + frames: 16, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + cast_slowmissiles: { + overlay: 'cast_slowmissiles', + filename: 'CastSlowMissiles', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 7, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + valkyriestart: { + overlay: 'valkyriestart', + filename: 'ValkyrieStart', + frames: 20, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 235, + blue: 155, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + shoutstart: { + overlay: 'shoutstart', + filename: 'ShoutCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + battleorderscast: { + overlay: 'battleorderscast', + filename: 'BattleOrdersCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 155, + green: 205, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + battlecommandcast: { + overlay: 'battlecommandcast', + filename: 'BattleCommandCast', + frames: 20, + animRate: 16, + trans: 3, + preDraw: true, + initRadius: 1, + radius: 8, + red: 255, + green: 185, + blue: 115, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + druid_fire_cast_1: { + overlay: 'druid_fire_cast_1', + filename: 'DruidFireCast1', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 255, + green: 175, + blue: 55, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + druid_fire_cast_2: { + overlay: 'druid_fire_cast_2', + filename: 'DruidFireCast2', + frames: 17, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 9, + red: 255, + green: 175, + blue: 55, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + wolf_into: { + overlay: 'wolf_into', + filename: 'WolfTransform', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 0, + radius: 0, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + bear_into: { + overlay: 'bear_into', + filename: 'BearTransform', + frames: 15, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 0, + radius: 0, + red: 255, + green: 255, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, + lightning: { + overlay: 'lightning', + filename: 'LightHit', + frames: 12, + animRate: 16, + trans: 3, + preDraw: false, + initRadius: 1, + radius: 8, + red: 192, + green: 215, + blue: 255, + numDirections: 1, + xOffset: 0, + yOffset: 0, + }, }) /** @@ -4085,12 +4583,87 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly> = Object 'bone wall': 'bonecast', boneprison: 'bonecast', 'bone prison': 'bonecast', + teeth: 'bonecast', + bonespear: 'bonecast', + 'bone spear': 'bonecast', + bonespirit: 'bonecast', + 'bone spirit': 'bonecast', + corpseexplosion: 'bonecast', + 'corpse explosion': 'bonecast', + poisonexplosion: 'bonecast', + 'poison explosion': 'bonecast', + poisonnova: 'bonecast', + 'poison nova': 'bonecast', + amplifydamage: 'cursecast', + 'amplify damage': 'cursecast', + dimvision: 'cursecast', + 'dim vision': 'cursecast', + weaken: 'cursecast', + ironmaiden: 'cursecast', + 'iron maiden': 'cursecast', + terror: 'cursecast', + confuse: 'cursecast', + lifetap: 'cursecast', + 'life tap': 'cursecast', + attract: 'cursecast', + decrepify: 'cursecast', + lowerresist: 'cursecast', + 'lower resist': 'cursecast', + holybolt: 'holy_bolt_cast', + 'holy bolt': 'holy_bolt_cast', + blessedhammer: 'holy_bolt_cast', + 'blessed hammer': 'holy_bolt_cast', + fistoftheheavens: 'fist_of_the_heavens_cast', + 'fist of the heavens': 'fist_of_the_heavens_cast', }) const NECROMANCER_SKILL_CAST_OVERLAYS: Readonly> = Object.freeze({ + 66: 'cursecast', // Amplify Damage + 67: 'bonecast', // Teeth 68: 'bonearmor_cast', // Bone Armor + 70: 'bonecast', // Raise Skeleton + 71: 'cursecast', // Dim Vision + 72: 'cursecast', // Weaken + 74: 'bonecast', // Corpse Explosion + 76: 'cursecast', // Iron Maiden + 77: 'cursecast', // Terror 78: 'bonecast', // Bone Wall + 80: 'bonecast', //Raise Skeletal Mage + 81: 'cursecast', // Confuse + 82: 'cursecast', // Life Tap + 83: 'bonecast', // Poison Explosion + 84: 'bonecast', // Bone Spear + 86: 'cursecast', // Attract + 87: 'cursecast', // Decrepify 88: 'bonecast', // Bone Prison + 91: 'cursecast', // Lower Resist + 92: 'bonecast', // Poison Nova + 93: 'bonecast', // Bone Spirit + 95: 'bonecast', // Revive +}) + +const OTHER_CLASS_SKILL_CAST_OVERLAYS: Readonly> = Object.freeze({ + // Amazon + 8: 'cast_innersight', // Inner Sight + 17: 'cast_slowmissiles', // Slow Missiles + 32: 'valkyriestart', // Valkyrie + // Paladin + 101: 'holy_bolt_cast', // Holy Bolt + 112: 'holy_bolt_cast', // Blessed Hammer + 116: 'conversioncast', // Conversion + 121: 'fist_of_the_heavens_cast', // Fist of the Heavens + // Barbarian + 138: 'shoutstart', // Shout + 149: 'battleorderscast', // Battle Orders + 155: 'battlecommandcast', // Battle Command + // Druid + 223: 'wolf_into', // Werewolf + 225: 'druid_fire_cast_1', // Firestorm + 228: 'bear_into', // Werebear + 229: 'druid_fire_cast_1', // Molten Boulder + 234: 'druid_fire_cast_1', // Fissure + 244: 'druid_fire_cast_2', // Volcano + 249: 'druid_fire_cast_2', // Armageddon }) /** @@ -4101,6 +4674,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine return ( SORCERESS_SKILL_CAST_OVERLAYS[skillId] ?? NECROMANCER_SKILL_CAST_OVERLAYS[skillId] ?? + OTHER_CLASS_SKILL_CAST_OVERLAYS[skillId] ?? BATCH1_SKILLS[skillId]?.castOverlay ) } @@ -4109,6 +4683,7 @@ export function getSkillCastOverlay(skillId: number | string): string | undefine return ( SORCERESS_SKILL_CAST_OVERLAYS[num] ?? NECROMANCER_SKILL_CAST_OVERLAYS[num] ?? + OTHER_CLASS_SKILL_CAST_OVERLAYS[num] ?? BATCH1_SKILLS[num]?.castOverlay ) } diff --git a/src/netproto/d2gs/s2c/misc.ts b/src/netproto/d2gs/s2c/misc.ts index 21b452e..513341a 100644 --- a/src/netproto/d2gs/s2c/misc.ts +++ b/src/netproto/d2gs/s2c/misc.ts @@ -71,12 +71,17 @@ export function decode0x73CreateMissile(pkt: Uint8Array): ServerEvent { const y = r.u16LE() const targetX = r.u16LE() const targetY = r.u16LE() + const level = r.remaining >= 1 ? r.u8() : 1 + const ownerType = r.remaining >= 1 ? clampUnitType(r.u8()) : 0 + const ownerId = r.remaining >= 4 ? r.u32LE() : 0 return { type: 'SkillCast', caster: { unitType: 3, id: missileClassId }, skill: missileClassId, - level: 1, + level: level || 1, + at: { x, y }, target: { x: targetX || x, y: targetY || y }, + owner: { unitType: ownerType, id: ownerId }, } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 5e20476..9ab0098 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -86,6 +86,8 @@ export type ServerEvent = readonly skillId?: number | undefined readonly level: number readonly target: UnitRef | Pt + readonly at?: Pt | undefined + readonly owner?: UnitRef | undefined } | { readonly type: 'StatSet' diff --git a/tests/client/client-world.test.ts b/tests/client/client-world.test.ts index 3c2f423..5faeb32 100644 --- a/tests/client/client-world.test.ts +++ b/tests/client/client-world.test.ts @@ -678,4 +678,290 @@ describe('ClientWorld authoritative state mirror', () => { expect(world.containers.tradeGrid).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) + }) }) + diff --git a/tests/client/hud-session-play.test.ts b/tests/client/hud-session-play.test.ts index 5e51ae5..3d4666a 100644 --- a/tests/client/hud-session-play.test.ts +++ b/tests/client/hud-session-play.test.ts @@ -1031,5 +1031,86 @@ describe('Milestone M5 — HudModel, CommandMapper, and OnlineSession', () => { session.destroy() } }) + + it('projects canonical 1.13c spell cast overlays, flying missiles, LevelView wall collisions, and one-shot impact explosions into SceneSource drawables (#550)', () => { + const session = new OnlineSession({ + settingsStore: createSettingsStore(), + }) + + const walkable = new Uint8Array(200 * 200).fill(1) + const blocked = new Uint8Array(200 * 200) + // Block sub-tile at world (5056, 5050) -> local (56, 50) + walkable[50 * 200 + 56] = 0 + blocked[50 * 200 + 56] = 1 + + session.levelView = { + act: 0, + areaId: 2, + subOriginX: 5000, + subOriginY: 5000, + subWidth: 200, + subHeight: 200, + isoOriginX: 0, + isoOriginY: 0, + cellsX: 40, + cellsY: 40, + gridWidth: 200, + blocked, + walkable, + collisionMasks: new Uint16Array(200 * 200), + rooms: [], + floors: [], + shadows: [], + walls: [], + roofs: [], + warps: [], + presets: [], + } as any + + session.handleServerEvent({ type: 'LoadAct', act: 0, mapSeed: 0x11223344, areaId: 2 }) + session.handleServerEvent({ + type: 'UnitAssign', + unit: { unitType: 0, id: 1, classId: 1, name: 'Sorceress', x: 5050, y: 5050 }, + isSelf: true, + }) + session.handleServerEvent({ + type: 'ActiveSkill', + unitId: 1, + hand: 'right', + skill: 47, // Fire Ball + }) + + // Cast Fire Ball toward (5070, 5050); wall is at (5056, 5050) + session.useSkillAt(5070, 5050, 'right', true) + + const snap0 = session.sceneSource.buildFrameSnapshot() + expect(snap0.entities.overlayDrawables.some((o) => o.token === 'fire_cast_2' && o.loop === false)).toBe(true) + const fireball0 = snap0.entities.missileDrawables.find((m) => m.missileKey === 'fireball') + expect(fireball0).toBeDefined() + expect(fireball0?.blendMode).toBe('additive') + expect(snap0.entities.pointLights.length).toBeGreaterThanOrEqual(2) + + // Advance 3 ticks (120ms) -> fireball collides with blocked LevelView sub-tile at (5056, 5050) + // and spawns one-shot explodingarrowexp + session.tick(120, 1120) + const snapImpact = session.sceneSource.buildFrameSnapshot() + expect(snapImpact.entities.missileDrawables.some((m) => m.missileKey === 'fireball')).toBe(false) + const expDrawable = snapImpact.entities.missileDrawables.find( + (m) => m.missileKey === 'explodingarrowexp', + ) + expect(expDrawable).toBeDefined() + expect(expDrawable?.loopAnim).toBe(false) + + // Advance past explosion and cast duration -> explosion removed and caster returns to neutral + for (let i = 1; i <= 20; i++) { + session.tick(40, 1120 + i * 40) + } + const snapAfter = session.sceneSource.buildFrameSnapshot() + expect(snapAfter.entities.missileDrawables).toHaveLength(0) + expect(snapAfter.entities.overlayDrawables).toHaveLength(0) + + session.destroy() + }) }) }) +