805 lines
23 KiB
TypeScript
805 lines
23 KiB
TypeScript
/**
|
|
* Shared Test Helpers for the Diablo II v1.13c Parity E2E Test Suite (`tests/e2e-parity/`).
|
|
*
|
|
* Provides authoritative fixtures, mock environments, math oracles, and packet
|
|
* validators adhering strictly to Diablo II v1.13c decompiled specifications.
|
|
*/
|
|
|
|
import { vi } from 'vitest'
|
|
import {
|
|
ORTHO_CELL_HEIGHT,
|
|
ORTHO_CELL_WIDTH,
|
|
ORTHO_SUB_TILE_HEIGHT,
|
|
ORTHO_SUB_TILE_WIDTH,
|
|
COLLIDE_BLANK,
|
|
COLLIDE_CORPSE,
|
|
COLLIDE_DOOR,
|
|
COLLIDE_ITEM,
|
|
COLLIDE_MASK_INVALID,
|
|
COLLIDE_MISSILE,
|
|
COLLIDE_MISSILE_BARRIER,
|
|
COLLIDE_MONSTER,
|
|
COLLIDE_NONE,
|
|
COLLIDE_NOPLAYER,
|
|
COLLIDE_NO_PATH,
|
|
COLLIDE_OBJECT,
|
|
COLLIDE_PET,
|
|
COLLIDE_PLAYER,
|
|
COLLIDE_PRESET,
|
|
COLLIDE_VISIBLE,
|
|
COLLIDE_WALL,
|
|
COLLIDE_WATER,
|
|
pickVariant,
|
|
} from '../../src/common/world/d2map.ts'
|
|
import { DRLG_UNIVERSAL_DT1 } from '../../src/common/drlg/drlg-map.ts'
|
|
import {
|
|
CHAR_ANIM_CLIPS_113C,
|
|
computeAnimFrameIndex,
|
|
computeDiminishingRate113c,
|
|
computeEffectiveFcr,
|
|
computeEffectiveFhr,
|
|
computeEffectiveIas,
|
|
computeOneShotDurationMs,
|
|
computeOneShotTicks,
|
|
computeStepSpeed256,
|
|
D2_TICK_MS,
|
|
STAT_FASTERATTACKRATE,
|
|
STAT_FASTERBLOCKRATE,
|
|
STAT_FASTERCASTRATE,
|
|
STAT_FASTERHITRECOVERY,
|
|
STAT_SKILL_IAS,
|
|
type CharAnimClip113c,
|
|
type CharAnimClipKey,
|
|
} from '../../src/common/units/char-anim-speed.ts'
|
|
import {
|
|
calculateHitChance,
|
|
calculatePlayerBlockChance,
|
|
} from '../../src/common/skills/formulas.ts'
|
|
import {
|
|
CANONICAL_113C_MISSILES,
|
|
getMissileTxtData,
|
|
getOverlayTxtData,
|
|
getSkillCastOverlay,
|
|
} from '../../src/common/skills/missiles-data.ts'
|
|
import {
|
|
C2S_PACKET_SIZES,
|
|
S2C_PACKET_SIZES,
|
|
BncsCreateAccountError,
|
|
BncsCreateAccountStatus,
|
|
BncsOpcode,
|
|
encodeBncsCreateAccount2,
|
|
type ClientCommand,
|
|
type ItemData,
|
|
type ServerEvent,
|
|
} from '../../src/netproto/index.ts'
|
|
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
|
import { ClientUnit } from '../../src/client/world/client-unit.ts'
|
|
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
|
|
import {
|
|
VIEWPORT_800x600,
|
|
getViewportProfile,
|
|
subTileToWorldOrtho,
|
|
worldOrthoToSubTile,
|
|
worldToScreen,
|
|
screenToWorld,
|
|
subTileToScreen,
|
|
screenToSubTile,
|
|
computeViewportAnchors,
|
|
computePanelCameraShift,
|
|
} from '../../src/client/view/viewport-profile.ts'
|
|
import { buildCollisionGridFromLevelView } from '../../src/client/view/scene-source.ts'
|
|
import {
|
|
buildSceneDrawables,
|
|
transToBlendMode,
|
|
type RenderableUnitInput,
|
|
} from '../../src/client/view/unit-drawables.ts'
|
|
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
|
|
import {
|
|
INV_COLS,
|
|
INV_ROWS,
|
|
INV_GRID_ORIGIN,
|
|
INVENTORY_TXT_LAYOUTS,
|
|
getInventoryLayout,
|
|
InventoryPanel,
|
|
WEAPON_SWAP_TABS_BOUNDS,
|
|
resolveWeaponSwapTabClick,
|
|
STASH_GOLD_CAP,
|
|
getInventoryGoldLimit,
|
|
findFreeGridSlot,
|
|
type UiInventoryItem,
|
|
type GridPlacement,
|
|
} from '../../src/client/ui/inventory.ts'
|
|
import {
|
|
CUBE_GRID_ORIGIN,
|
|
WorldPanelsHud,
|
|
getActQuests,
|
|
getActWaypoints,
|
|
computeNpcMenuAnchorY,
|
|
} from '../../src/client/ui/world-panels.ts'
|
|
import {
|
|
CharacterSheetPanel,
|
|
type CharacterAttributes,
|
|
} from '../../src/client/ui/character-sheet.ts'
|
|
import { D2FontRenderer } from '../../src/client/ui/font.ts'
|
|
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
|
import { Minimap } from '../../src/client/ui/minimap.ts'
|
|
import { onlineItemDataToUiInventoryItem } from '../../src/common/items/item-bridge.ts'
|
|
import {
|
|
BeltModel,
|
|
getBeltRowsForCode,
|
|
getBeltSlotBox,
|
|
itemToBeltPotion,
|
|
BELT_COLS,
|
|
BELT_ROWS,
|
|
} from '../../src/common/items/belt-model.ts'
|
|
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
|
|
import {
|
|
AVATAR_COMPONENT_NAMES,
|
|
CAMPFIRE_FLAME_CONTROL_SPEC,
|
|
CAMPFIRE_CLASS_SPECS,
|
|
CANONICAL_D2LAUNCH_TBL_BY_ID,
|
|
D2_COLORS_TXT_PALETTE,
|
|
charSummaryToRosterCharacter,
|
|
createInitialFlowState,
|
|
enterCharCreate,
|
|
filterAccountName,
|
|
filterCharNameInput,
|
|
formatCharacterLevelAndClass,
|
|
isValidAccountChar,
|
|
isValidCharacterName,
|
|
canAcceptCharNameChar,
|
|
mapCreateAccountServerStatus,
|
|
parseMcpAvatarStatstring,
|
|
resolveClassNameByTbl,
|
|
resolveStringByTblId,
|
|
selectCampfireClass,
|
|
toggleChatGem,
|
|
validateNewAccountInputs,
|
|
} from '../../src/client/frontend/flow-state.ts'
|
|
import {
|
|
FrontendRenderer,
|
|
CAMPFIRE_OVERLAY_SPECS,
|
|
type FrontendManifest,
|
|
} from '../../src/client/frontend/frontend-renderer.ts'
|
|
import {
|
|
lookupTbl,
|
|
requireTbl,
|
|
getLang,
|
|
setLang,
|
|
getGameLang,
|
|
setGameLang,
|
|
getTblLang,
|
|
resolveLangFromPathname,
|
|
detectLangFromUrl,
|
|
} from '../../src/client/i18n/lang.ts'
|
|
import { isTownLevel } from '../../src/common/world/scene-population.ts'
|
|
import { decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts'
|
|
|
|
export {
|
|
isTownLevel,
|
|
decode0x8ECorpseAssign,
|
|
// Map & Collision Constants
|
|
ORTHO_CELL_HEIGHT,
|
|
ORTHO_CELL_WIDTH,
|
|
ORTHO_SUB_TILE_HEIGHT,
|
|
ORTHO_SUB_TILE_WIDTH,
|
|
COLLIDE_BLANK,
|
|
COLLIDE_CORPSE,
|
|
COLLIDE_DOOR,
|
|
COLLIDE_ITEM,
|
|
COLLIDE_MASK_INVALID,
|
|
COLLIDE_MISSILE,
|
|
COLLIDE_MISSILE_BARRIER,
|
|
COLLIDE_MONSTER,
|
|
COLLIDE_NONE,
|
|
COLLIDE_NOPLAYER,
|
|
COLLIDE_NO_PATH,
|
|
COLLIDE_OBJECT,
|
|
COLLIDE_PET,
|
|
COLLIDE_PLAYER,
|
|
COLLIDE_PRESET,
|
|
COLLIDE_VISIBLE,
|
|
COLLIDE_WALL,
|
|
COLLIDE_WATER,
|
|
pickVariant,
|
|
DRLG_UNIVERSAL_DT1,
|
|
|
|
// Viewport & Projection
|
|
VIEWPORT_800x600,
|
|
getViewportProfile,
|
|
subTileToWorldOrtho,
|
|
worldOrthoToSubTile,
|
|
worldToScreen,
|
|
screenToWorld,
|
|
subTileToScreen,
|
|
screenToSubTile,
|
|
computeViewportAnchors,
|
|
computePanelCameraShift,
|
|
buildCollisionGridFromLevelView,
|
|
|
|
// Animation & Speed Constants / Math
|
|
CHAR_ANIM_CLIPS_113C,
|
|
computeAnimFrameIndex,
|
|
computeDiminishingRate113c,
|
|
computeEffectiveFcr,
|
|
computeEffectiveFhr,
|
|
computeEffectiveIas,
|
|
computeOneShotDurationMs,
|
|
computeOneShotTicks,
|
|
computeStepSpeed256,
|
|
D2_TICK_MS,
|
|
STAT_FASTERATTACKRATE,
|
|
STAT_FASTERBLOCKRATE,
|
|
STAT_FASTERCASTRATE,
|
|
STAT_FASTERHITRECOVERY,
|
|
STAT_SKILL_IAS,
|
|
|
|
// Formulas
|
|
calculateHitChance,
|
|
calculatePlayerBlockChance,
|
|
|
|
// Missiles, Scene Drawables & Overlays
|
|
CANONICAL_113C_MISSILES,
|
|
getMissileTxtData,
|
|
getOverlayTxtData,
|
|
getSkillCastOverlay,
|
|
buildSceneDrawables,
|
|
transToBlendMode,
|
|
type RenderableUnitInput,
|
|
|
|
// Protocol & Tables
|
|
C2S_PACKET_SIZES,
|
|
S2C_PACKET_SIZES,
|
|
BncsCreateAccountError,
|
|
BncsCreateAccountStatus,
|
|
BncsOpcode,
|
|
encodeBncsCreateAccount2,
|
|
|
|
// Client Classes, Combat, UI, & Items
|
|
ClientWorld,
|
|
ClientUnit,
|
|
CommandMapper,
|
|
ViewportCamera,
|
|
HudModel,
|
|
HudManager,
|
|
INV_COLS,
|
|
INV_ROWS,
|
|
INV_GRID_ORIGIN,
|
|
INVENTORY_TXT_LAYOUTS,
|
|
getInventoryLayout,
|
|
InventoryPanel,
|
|
WEAPON_SWAP_TABS_BOUNDS,
|
|
resolveWeaponSwapTabClick,
|
|
STASH_GOLD_CAP,
|
|
getInventoryGoldLimit,
|
|
findFreeGridSlot,
|
|
type UiInventoryItem,
|
|
type GridPlacement,
|
|
CUBE_GRID_ORIGIN,
|
|
WorldPanelsHud,
|
|
getActQuests,
|
|
getActWaypoints,
|
|
computeNpcMenuAnchorY,
|
|
CharacterSheetPanel,
|
|
type CharacterAttributes,
|
|
D2FontRenderer,
|
|
Minimap,
|
|
onlineItemDataToUiInventoryItem,
|
|
BeltModel,
|
|
getBeltRowsForCode,
|
|
getBeltSlotBox,
|
|
itemToBeltPotion,
|
|
BELT_COLS,
|
|
BELT_ROWS,
|
|
|
|
// Frontend flow, Renderer, & i18n
|
|
AVATAR_COMPONENT_NAMES,
|
|
CAMPFIRE_FLAME_CONTROL_SPEC,
|
|
CAMPFIRE_CLASS_SPECS,
|
|
CAMPFIRE_OVERLAY_SPECS,
|
|
CANONICAL_D2LAUNCH_TBL_BY_ID,
|
|
D2_COLORS_TXT_PALETTE,
|
|
FrontendRenderer,
|
|
charSummaryToRosterCharacter,
|
|
computeMonsterMoveSpeedSubTilesPerSec,
|
|
createInitialFlowState,
|
|
enterCharCreate,
|
|
filterAccountName,
|
|
filterCharNameInput,
|
|
formatCharacterLevelAndClass,
|
|
isValidAccountChar,
|
|
isValidCharacterName,
|
|
canAcceptCharNameChar,
|
|
mapCreateAccountServerStatus,
|
|
parseMcpAvatarStatstring,
|
|
resolveClassNameByTbl,
|
|
resolveStringByTblId,
|
|
selectCampfireClass,
|
|
toggleChatGem,
|
|
validateNewAccountInputs,
|
|
lookupTbl,
|
|
requireTbl,
|
|
getLang,
|
|
setLang,
|
|
getGameLang,
|
|
setGameLang,
|
|
getTblLang,
|
|
resolveLangFromPathname,
|
|
detectLangFromUrl,
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Coordinate Conversions (Sub-Tile <-> Ortho Isometric Screen)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Projects continuous sub-tile coordinates into 2:1 isometric screen coordinates
|
|
* by delegating directly to production `subTileToWorldOrtho` (`src/client/view/viewport-profile.ts`).
|
|
*/
|
|
export function subTileToOrthoScreen(subX: number, subY: number): { x: number; y: number } {
|
|
return subTileToWorldOrtho(subX, subY, 0, 0)
|
|
}
|
|
|
|
/**
|
|
* Inverse projects 2:1 isometric screen coordinates back to continuous sub-tile coordinates
|
|
* by delegating directly to production `worldOrthoToSubTile` (`src/client/view/viewport-profile.ts`).
|
|
*/
|
|
export function orthoScreenToSubTile(screenX: number, screenY: number): { x: number; y: number } {
|
|
return worldOrthoToSubTile(screenX, screenY, 0, 0)
|
|
}
|
|
|
|
/**
|
|
* Converts tile cell coordinates (each 5x5 sub-tiles) to top-left sub-tile coordinates.
|
|
*/
|
|
export function tileToSubTile(tileX: number, tileY: number, subOffset = { x: 0, y: 0 }): { x: number; y: number } {
|
|
return {
|
|
x: tileX * 5 + subOffset.x,
|
|
y: tileY * 5 + subOffset.y,
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Production-Backed Collision Grid Oracle
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface CollisionGridOracle {
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly world: ClientWorld
|
|
getCollision(subX: number, subY: number): number
|
|
setCollision(subX: number, subY: number, mask: number): void
|
|
isWalkable(subX: number, subY: number): boolean
|
|
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean
|
|
}
|
|
|
|
/**
|
|
* Creates a collision grid backed by a real `ClientWorld` instance (`setSubTileBlocked` / `isSubTileBlocked`)
|
|
* alongside 1.13c multi-layer collision bitmasks (`COLLIDE_*` from `src/common/world/d2map.ts`).
|
|
*/
|
|
export function createCollisionGrid(width: number, height: number, defaultMask = COLLIDE_NONE): CollisionGridOracle {
|
|
const data = new Uint16Array(width * height).fill(defaultMask)
|
|
const world = new ClientWorld()
|
|
const groundBlockingMask = COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_OBJECT | COLLIDE_DOOR
|
|
|
|
if ((defaultMask & groundBlockingMask) !== 0) {
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
world.setSubTileBlocked(x, y, true)
|
|
}
|
|
}
|
|
}
|
|
|
|
const oracle: CollisionGridOracle = {
|
|
width,
|
|
height,
|
|
world,
|
|
getCollision(subX: number, subY: number): number {
|
|
if (subX < 0 || subX >= width || subY < 0 || subY >= height) {
|
|
return COLLIDE_BLANK | COLLIDE_WALL // Void out-of-bounds protection
|
|
}
|
|
return data[subY * width + subX]!
|
|
},
|
|
setCollision(subX: number, subY: number, mask: number): void {
|
|
if (subX >= 0 && subX < width && subY >= 0 && subY < height) {
|
|
data[subY * width + subX] = mask
|
|
world.setSubTileBlocked(subX, subY, (mask & groundBlockingMask) !== 0)
|
|
}
|
|
},
|
|
isWalkable(subX: number, subY: number): boolean {
|
|
if (subX < 0 || subX >= width || subY < 0 || subY >= height) {
|
|
return false
|
|
}
|
|
const flags = oracle.getCollision(subX, subY)
|
|
const blockedByMask = (flags & groundBlockingMask) !== 0
|
|
const blockedInWorld = world.isSubTileBlocked(subX, subY)
|
|
return !blockedByMask && !blockedInWorld
|
|
},
|
|
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean {
|
|
// Bresenham raycast checking COLLIDE_VISIBLE and COLLIDE_MISSILE_BARRIER
|
|
let dx = Math.abs(x1 - x0)
|
|
let dy = Math.abs(y1 - y0)
|
|
let sx = x0 < x1 ? 1 : -1
|
|
let sy = y0 < y1 ? 1 : -1
|
|
let err = dx - dy
|
|
let currX = x0
|
|
let currY = y0
|
|
|
|
while (true) {
|
|
const mask = oracle.getCollision(currX, currY)
|
|
if ((mask & (COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER | COLLIDE_WALL)) !== 0) {
|
|
// Blocked by barrier
|
|
if (currX !== x0 || currY !== y0) {
|
|
return false
|
|
}
|
|
}
|
|
if (currX === x1 && currY === y1) break
|
|
const e2 = 2 * err
|
|
if (e2 > -dy) {
|
|
err -= dy
|
|
currX += sx
|
|
}
|
|
if (e2 < dx) {
|
|
err += dx
|
|
currY += sy
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
}
|
|
|
|
return oracle
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Item Data Builder
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function makeParityItem(partial: Partial<ItemData>): ItemData {
|
|
const id = partial.id ?? partial.itemId ?? 1000
|
|
const code = partial.code ?? 'cap'
|
|
const container = partial.container ?? 'inventory'
|
|
return {
|
|
id,
|
|
itemId: id,
|
|
opcode: 0x9c,
|
|
action: 0,
|
|
category: 0,
|
|
flags: 0,
|
|
rawFlags: 0,
|
|
version: 101,
|
|
location: 0,
|
|
container,
|
|
bodyLoc: 0,
|
|
equippedSlot: 0,
|
|
gridX: 0,
|
|
gridY: 0,
|
|
storePage: 0,
|
|
storagePage: 0,
|
|
worldX: 0,
|
|
worldY: 0,
|
|
x: 0,
|
|
y: 0,
|
|
code,
|
|
rawCode: code.padEnd(4, ' '),
|
|
compact: false,
|
|
simple: false,
|
|
identified: true,
|
|
socketed: false,
|
|
ethereal: false,
|
|
runeword: false,
|
|
personalized: false,
|
|
isEar: false,
|
|
newItem: false,
|
|
starterItem: false,
|
|
broken: false,
|
|
inSocket: false,
|
|
equipped: false,
|
|
socketedChildCount: 0,
|
|
invWidth: 2,
|
|
invHeight: 2,
|
|
totalSockets: 0,
|
|
usedSockets: 0,
|
|
quantity: 1,
|
|
stats: [],
|
|
...partial,
|
|
...(partial.id !== undefined || partial.itemId !== undefined ? { id, itemId: id } : {}),
|
|
} as ItemData
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Parity Client Session Fixture
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface ParitySessionFixture {
|
|
readonly world: ClientWorld
|
|
readonly hudModel: HudModel
|
|
readonly sentCommands: ClientCommand[]
|
|
readonly selfUnit: import('../../src/client/world/client-unit.ts').ClientUnit | undefined
|
|
tick(ticks?: number): void
|
|
applyServerEvents(...events: ServerEvent[]): void
|
|
clearCommands(): void
|
|
}
|
|
|
|
export function createParitySessionFixture(options: { selfId?: number; charClass?: number } = {}): ParitySessionFixture {
|
|
const selfId = options.selfId ?? 1
|
|
const world = new ClientWorld()
|
|
const sentCommands: ClientCommand[] = []
|
|
|
|
// Initialize self player in ClientWorld via PlayerAssign
|
|
world.apply({
|
|
type: 'PlayerAssign',
|
|
unitId: selfId,
|
|
classId: options.charClass ?? 1, // 1 = Sorceress
|
|
x: 100,
|
|
y: 100,
|
|
mode: 1, // Neutral
|
|
})
|
|
|
|
const hudModel = new HudModel({
|
|
world,
|
|
onCommand: (cmd) => sentCommands.push(cmd),
|
|
})
|
|
hudModel.syncFromWorld()
|
|
|
|
return {
|
|
world,
|
|
hudModel,
|
|
sentCommands,
|
|
get selfUnit() {
|
|
return world.getUnit('player', world.selfId)
|
|
},
|
|
tick(ticks = 1) {
|
|
for (let i = 0; i < ticks; i++) {
|
|
world.tick(D2_TICK_MS)
|
|
}
|
|
hudModel.syncFromWorld()
|
|
},
|
|
applyServerEvents(...events: ServerEvent[]) {
|
|
for (const ev of events) {
|
|
world.apply(ev)
|
|
}
|
|
hudModel.syncFromWorld()
|
|
},
|
|
clearCommands() {
|
|
sentCommands.length = 0
|
|
},
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// HUD & Frontend Canvas Recording Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function createMockHudCanvasContext() {
|
|
const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = []
|
|
const font = new D2FontRenderer()
|
|
vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => {
|
|
drawTextCalls.push({
|
|
text: String(text),
|
|
x,
|
|
y,
|
|
...(opts?.color !== undefined ? { color: opts.color } : {}),
|
|
...(opts?.font !== undefined ? { font: opts.font } : {}),
|
|
})
|
|
return String(text).length * 8
|
|
})
|
|
const ctx = {
|
|
save: vi.fn(),
|
|
restore: vi.fn(),
|
|
translate: vi.fn(),
|
|
drawImage: vi.fn(),
|
|
fillRect: vi.fn(),
|
|
strokeRect: vi.fn(),
|
|
beginPath: vi.fn(),
|
|
moveTo: vi.fn(),
|
|
lineTo: vi.fn(),
|
|
arc: vi.fn(),
|
|
fill: vi.fn(),
|
|
stroke: vi.fn(),
|
|
fillText: vi.fn(),
|
|
measureText: vi.fn((t: string) => ({ width: t.length * 7 })),
|
|
fillStyle: '#000',
|
|
strokeStyle: '#000',
|
|
lineWidth: 1,
|
|
globalAlpha: 1,
|
|
} as unknown as CanvasRenderingContext2D
|
|
return { ctx, font, drawTextCalls }
|
|
}
|
|
|
|
export interface FrontendDrawImageCall {
|
|
readonly img: unknown
|
|
readonly args: readonly number[]
|
|
readonly compositeOp: string
|
|
readonly alpha: number
|
|
readonly filter: string
|
|
}
|
|
|
|
export function createRecordingFrontendRenderer() {
|
|
const drawCalls: FrontendDrawImageCall[] = []
|
|
const stateStack: Array<{ compositeOp: string; alpha: number; filter: string; fillStyle: string }> = []
|
|
|
|
const ctx = {
|
|
imageSmoothingEnabled: false,
|
|
globalCompositeOperation: 'source-over',
|
|
globalAlpha: 1,
|
|
filter: 'none',
|
|
fillStyle: '#000000',
|
|
strokeStyle: '#000000',
|
|
lineWidth: 1,
|
|
save() {
|
|
stateStack.push({
|
|
compositeOp: this.globalCompositeOperation,
|
|
alpha: this.globalAlpha,
|
|
filter: this.filter,
|
|
fillStyle: this.fillStyle,
|
|
})
|
|
},
|
|
restore() {
|
|
const prev = stateStack.pop()
|
|
if (prev) {
|
|
this.globalCompositeOperation = prev.compositeOp
|
|
this.globalAlpha = prev.alpha
|
|
this.filter = prev.filter
|
|
this.fillStyle = prev.fillStyle
|
|
}
|
|
},
|
|
clearRect() {},
|
|
fillRect() {},
|
|
strokeRect() {},
|
|
beginPath() {},
|
|
moveTo() {},
|
|
lineTo() {},
|
|
stroke() {},
|
|
rect() {},
|
|
clip() {},
|
|
drawImage(img: unknown, ...args: number[]) {
|
|
drawCalls.push({
|
|
img,
|
|
args,
|
|
compositeOp: this.globalCompositeOperation,
|
|
alpha: this.globalAlpha,
|
|
filter: this.filter,
|
|
})
|
|
},
|
|
}
|
|
|
|
const fakeCanvas = {
|
|
width: 800,
|
|
height: 600,
|
|
style: { cursor: 'none' },
|
|
getContext: () => ctx,
|
|
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
|
} as unknown as HTMLCanvasElement
|
|
|
|
const state = createInitialFlowState()
|
|
const renderer = new FrontendRenderer(fakeCanvas, state)
|
|
|
|
const stubImg = (id: string) => ({ __id: id, width: 800, height: 600 }) as unknown as HTMLImageElement
|
|
for (const key of [
|
|
'bg-main-menu.png',
|
|
'bg-char-create.png',
|
|
'bg-char-select.png',
|
|
'bg-waiting-room.png',
|
|
'popup-340x224.png',
|
|
'popup-okcancel.png',
|
|
'terms-of-use-bg.png',
|
|
'scrollbar.png',
|
|
'btn-popup-small.png',
|
|
'create-game-bg.png',
|
|
'charselectbox.png',
|
|
'realmselect.png',
|
|
'btn-realm-thin.png',
|
|
'btn-wide.png',
|
|
'btn-3wide.png',
|
|
'btn-wide02.png',
|
|
'btn-medium.png',
|
|
'btn-short.png',
|
|
'btn-tall.png',
|
|
'textbox.png',
|
|
'clickbox.png',
|
|
'joingameclickbox.png',
|
|
'radiobutton.png',
|
|
'gembutton.png',
|
|
'btn-chatleft.png',
|
|
'btn-chatrighttop.png',
|
|
'btn-chatright.png',
|
|
'btn-game.png',
|
|
'btn-create-cancel.png',
|
|
'btn-create-game.png',
|
|
'grey-level-box.png',
|
|
'cursor-hand.png',
|
|
]) {
|
|
renderer.images.set(key, stubImg(key))
|
|
}
|
|
|
|
const glyphs = Array.from({ length: 256 }, (_, code) => ({
|
|
code,
|
|
width: 8,
|
|
height: 14,
|
|
x: (code % 16) * 16,
|
|
y: Math.floor(code / 16) * 16,
|
|
frameW: 8,
|
|
frameH: 14,
|
|
}))
|
|
for (const fontName of [
|
|
'font6',
|
|
'font8',
|
|
'font16',
|
|
'font24',
|
|
'font30',
|
|
'font42',
|
|
'fontformal10',
|
|
'fontformal11',
|
|
'fontformal12',
|
|
'fontexocet8',
|
|
'fontexocet10',
|
|
'fontridiculous',
|
|
]) {
|
|
renderer.fontMetas[fontName] = {
|
|
name: fontName,
|
|
lineHeight: 14,
|
|
capHeight: 12,
|
|
atlasWidth: 256,
|
|
atlasHeight: 256,
|
|
glyphs,
|
|
}
|
|
for (const colorIdx of [0, 1, 2, 3, 4, 5, 7, 8, 9]) {
|
|
renderer.images.set(`fonts/${fontName}-c${colorIdx}.png`, stubImg(`fonts/${fontName}-c${colorIdx}.png`))
|
|
}
|
|
}
|
|
|
|
const canonicalStringsById = Object.fromEntries(
|
|
Object.entries(CANONICAL_D2LAUNCH_TBL_BY_ID).map(([id, val]) => [String(id), val]),
|
|
)
|
|
|
|
const minimalManifest: FrontendManifest = {
|
|
version: '1.13c',
|
|
decodedDc6Count: 0,
|
|
fontColors: [0, 1, 2, 3, 4, 5, 7, 8, 9],
|
|
animStrips: {},
|
|
campfireClasses: {} as FrontendManifest['campfireClasses'],
|
|
charAvatars: {},
|
|
strings: {
|
|
...canonicalStringsById,
|
|
'5127': 'Select Hero Class',
|
|
'5272': 'Select Your Character',
|
|
singlePlayer: 'SINGLE PLAYER',
|
|
battleNet: 'BATTLE.NET',
|
|
otherMultiplayer: 'OTHER MULTIPLAYER',
|
|
exitDiablo2: 'EXIT DIABLO II',
|
|
credits: 'CREDITS',
|
|
cinematics: 'CINEMATICS',
|
|
loginTitle: 'Battle.net Log In',
|
|
loginInstruction:
|
|
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
|
|
accountName: 'Account Name',
|
|
accountPassword: 'Password',
|
|
password: 'Password',
|
|
logIn: 'LOG IN',
|
|
changePassword: 'CHANGE PASSWORD',
|
|
createNewAccount: 'CREATE NEW ACCOUNT',
|
|
accountSettings: 'ACCOUNT SETTINGS',
|
|
selectCharacterTitle: 'Select Your Character',
|
|
createNewCharacter: 'CREATE NEW CHARACTER',
|
|
convertToExpansion: 'CONVERT TO EXPANSION',
|
|
deleteCharacter: 'DELETE CHARACTER',
|
|
'5273': 'CREATE NEW CHARACTER',
|
|
'5274': 'DELETE CHARACTER',
|
|
'22729': 'CONVERT TO EXPANSION',
|
|
cancel: 'CANCEL',
|
|
ok: 'OK',
|
|
tblById: {
|
|
...canonicalStringsById,
|
|
'5127': 'Select Hero Class',
|
|
'5272': 'Select Your Character',
|
|
},
|
|
} as unknown as FrontendManifest['strings'],
|
|
}
|
|
|
|
renderer.manifest = minimalManifest
|
|
renderer.ready = true
|
|
|
|
return { renderer, state, ctx, drawCalls }
|
|
}
|
|
|