988 lines
31 KiB
TypeScript
988 lines
31 KiB
TypeScript
/**
|
|
* Pure, deterministic state machine for the Diablo II v1.13c Front-End & Battle.net Flow:
|
|
*
|
|
* Stage 1: `main_menu` — Title screen (`gameselectscreenEXP.dc6` + burning D2 logo)
|
|
* Stage 2: `bnet_connecting` — Connecting modal (`PopUp_340x224.dc6`)
|
|
* `bnet_login` — Battle.net Login (`Account Name`, `Password`, mock auth)
|
|
* Stage 3: `char_create` — 7-Class Campfire Character Creation (`charactercreationscreenEXP.dc6`)
|
|
* Stage 4: `char_select` — 8-Slot Character Selection Roster (`characterselectscreenEXP.dc6`)
|
|
* Stage 5: `bnet_lobby` — Battle.net Lobby & Create Game Room (`waitingroombkgd.dc6` + `creategamebckg.dc6`)
|
|
* Stage 6: `door_loading` — 10-Frame Dark Wanderer Door Opening Loading Screen (`Loadingscreen.dc6`)
|
|
* Stage 7: `in_game` — Act 1 Rogue Encampment (`src/scene/act-scene.ts`)
|
|
*/
|
|
|
|
export type BnetFlowStage =
|
|
| 'main_menu'
|
|
| 'bnet_connecting'
|
|
| 'bnet_login'
|
|
| 'char_create'
|
|
| 'char_select'
|
|
| 'bnet_lobby'
|
|
| 'door_loading'
|
|
| 'in_game'
|
|
|
|
/** Campfire class IDs in left-to-right screen order (`D2Launch.dll` @ `0x6fa68df8`). */
|
|
export type CampfireClassId = 'am' | 'as' | 'ne' | 'ba' | 'pa' | 'so' | 'dz'
|
|
|
|
/** In-game entity token (`as` maps to `ai` for Assassin). */
|
|
export type HeroToken = 'am' | 'ai' | 'ne' | 'ba' | 'pa' | 'so' | 'dz'
|
|
|
|
export type CampfireAnimPhase = 'nu1' | 'nu2' | 'fw' | 'nu3' | 'bw'
|
|
|
|
export type GameDifficulty = 'normal' | 'nightmare' | 'hell'
|
|
|
|
/** Fixed 25fps front-end tick budget (40ms per tick, `D2Win.dll` @ `0x6f8f8980`). */
|
|
export const FRONTEND_TICK_MS = 40
|
|
|
|
/** Maximum catch-up ticks allowed in a single frame step to prevent spiral-of-death bursts. */
|
|
export const FRONTEND_MAX_CATCHUP_TICKS = 5
|
|
|
|
/** Maximum frame delta (in milliseconds) accumulated in a single frame step (`5 * 40ms = 200ms`). */
|
|
export const FRONTEND_MAX_FRAME_DELTA_MS = FRONTEND_TICK_MS * FRONTEND_MAX_CATCHUP_TICKS
|
|
|
|
export interface CampfireClassSlotState {
|
|
readonly id: CampfireClassId
|
|
readonly heroToken: HeroToken
|
|
readonly nameEn: string
|
|
/** Screen anchor X (`D2Launch.dll` @ `0x6fa68df8`). */
|
|
readonly anchorX: number
|
|
/** Screen anchor Y (bottom edge, `D2Launch.dll` @ `0x6fa68df8`). */
|
|
readonly anchorY: number
|
|
/** Interactive bounding box in 800x600 space (`left, top, width, height`). */
|
|
readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
phase: CampfireAnimPhase
|
|
frameIndex: number
|
|
/** Ticks elapsed in the current animation frame (`0 .. rate - 1`). */
|
|
phaseTicks: number
|
|
frameCount: Record<CampfireAnimPhase, number>
|
|
/** Per-phase tick multiplier (`durMs = rate * 40ms`, `D2Launch.dll` @ `0x6fa60c08`). */
|
|
frameRate: Record<CampfireAnimPhase, number>
|
|
}
|
|
|
|
export interface HeroAvatarAnimState {
|
|
readonly heroToken: HeroToken
|
|
frameCount: number
|
|
/** 8.8 fixed-point COF animation speed (`D2Win.dll` @ `0x6f8ec48b`). */
|
|
animRate: number
|
|
/** 8.8 fixed-point accumulator (`acc += animRate` once per 40ms tick, `D2Win.dll` @ `0x6f8ee836`). */
|
|
acc: number
|
|
/** Current frame index (`acc >> 8`, wraps when `frame >= framesPerDir - 1`). */
|
|
frameIndex: number
|
|
}
|
|
|
|
export interface RosterCharacter {
|
|
readonly id: string
|
|
readonly name: string
|
|
readonly classId: CampfireClassId
|
|
readonly heroToken: HeroToken
|
|
readonly className: string
|
|
readonly level: number
|
|
isExpansion: boolean
|
|
readonly isHardcore: boolean
|
|
readonly isLadder: boolean
|
|
/** 5-bit character progression (`0..15`) from MCP 33-byte `statString[27] & 0x1f` (`D2Launch.dll` 0x6fa4c887). */
|
|
progression?: number | undefined
|
|
/** Optional explicit highest unlocked difficulty override. */
|
|
maxDifficulty?: GameDifficulty | undefined
|
|
readonly createdAt: number
|
|
}
|
|
|
|
export type ActiveInputField =
|
|
| 'accountName'
|
|
| 'accountPassword'
|
|
| 'charName'
|
|
| 'gameName'
|
|
| 'gamePassword'
|
|
| 'gameDescription'
|
|
| 'maxPlayers'
|
|
| 'charDifference'
|
|
| 'chatInput'
|
|
| null
|
|
|
|
export interface LobbyGameEntry {
|
|
readonly name: string
|
|
readonly description: string
|
|
readonly players: number
|
|
readonly maxPlayers: number
|
|
readonly difficulty: GameDifficulty
|
|
}
|
|
|
|
export interface BnetFlowState {
|
|
stage: BnetFlowStage
|
|
gateway: string
|
|
/** Ticks elapsed in current stage (25 ticks = 1 second). */
|
|
stageTicks: number
|
|
/** Global animation tick counter (25fps). */
|
|
globalTick: number
|
|
/** Fractional millisecond accumulator for fixed 25fps (40ms/tick) stepping. */
|
|
tickAccumulatorMs: number
|
|
/** Last `requestAnimationFrame` timestamp (ms) observed by `stepFlowFrame`, or `null` initially. */
|
|
lastFrameTimeMs: number | null
|
|
/** Whether live D2OnlineFlow is active (falls back to mock when false). */
|
|
onlineConnected?: boolean | undefined
|
|
|
|
// Stage 2: Battle.net Login
|
|
accountName: string
|
|
accountPassword: string
|
|
loginError: string | null
|
|
|
|
// Stage 3: Campfire Character Creation
|
|
campfireClasses: Record<CampfireClassId, CampfireClassSlotState>
|
|
hoveredClass: CampfireClassId | null
|
|
selectedClass: CampfireClassId | null
|
|
charName: string
|
|
isExpansion: boolean
|
|
isHardcore: boolean
|
|
isLadder: boolean
|
|
charCreateError: string | null
|
|
|
|
// Stage 4: 8-Slot Character Selection & Lobby TN Avatar 8.8 Fixed-Point Accumulators
|
|
roster: RosterCharacter[]
|
|
avatarAnims: Record<HeroToken, HeroAvatarAnimState>
|
|
selectedSlotIndex: number
|
|
deleteConfirmOpen: boolean
|
|
|
|
// Stage 5: Battle.net Lobby & Create Game Room
|
|
lobbySubPanel: 'create' | 'join' | 'channel' | 'ladder'
|
|
gemActivated: boolean
|
|
chatMessages: string[]
|
|
chatInput: string
|
|
gameName: string
|
|
gamePassword: string
|
|
gameDescription: string
|
|
maxPlayers: string
|
|
charDifferenceEnabled: boolean
|
|
charDifference: string
|
|
difficulty: GameDifficulty
|
|
createGameError: string | null
|
|
gameList?: LobbyGameEntry[] | undefined
|
|
realmGames: readonly {
|
|
readonly index: number
|
|
readonly name: string
|
|
readonly description: string
|
|
readonly playerCount: number
|
|
readonly flags: number
|
|
}[]
|
|
selectedGameName: string
|
|
lastError: string | null
|
|
statusText: string
|
|
|
|
// Stage 6: 10-Frame Door Opening Loading Screen
|
|
doorFrame: number
|
|
sceneReady: boolean
|
|
|
|
// Active keyboard focus
|
|
activeInput: ActiveInputField
|
|
}
|
|
|
|
export const CAMPFIRE_CLASS_ORDER: readonly CampfireClassId[] = [
|
|
'am',
|
|
'as',
|
|
'ne',
|
|
'ba',
|
|
'pa',
|
|
'so',
|
|
'dz',
|
|
] as const
|
|
|
|
export const HERO_TOKENS: readonly HeroToken[] = [
|
|
'am',
|
|
'ai',
|
|
'ne',
|
|
'ba',
|
|
'pa',
|
|
'so',
|
|
'dz',
|
|
] as const
|
|
|
|
export const HERO_TOKEN_ORDER: readonly HeroToken[] = HERO_TOKENS
|
|
|
|
export const CAMPFIRE_CLASS_SPECS: Readonly<
|
|
Record<
|
|
CampfireClassId,
|
|
{
|
|
readonly id: CampfireClassId
|
|
readonly heroToken: HeroToken
|
|
readonly nameEn: string
|
|
readonly anchorX: number
|
|
readonly anchorY: number
|
|
readonly hitBox: { readonly x: number; readonly y: number; readonly w: number; readonly h: number }
|
|
readonly defaultFrames: Record<CampfireAnimPhase, number>
|
|
/** 1.13c `D2Launch.dll` `@ 0x6fa60c08` per-mode tick multiplier (`durMs = rate * 40ms`). */
|
|
readonly defaultRates: Record<CampfireAnimPhase, number>
|
|
}
|
|
>
|
|
> = {
|
|
am: {
|
|
id: 'am',
|
|
heroToken: 'am',
|
|
nameEn: 'Amazon',
|
|
anchorX: 100,
|
|
anchorY: 337,
|
|
hitBox: { x: 56, y: 153, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 26, nu2: 56, fw: 28, nu3: 12, bw: 22 },
|
|
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
|
|
},
|
|
as: {
|
|
id: 'as',
|
|
heroToken: 'ai',
|
|
nameEn: 'Assassin',
|
|
anchorX: 232,
|
|
anchorY: 364,
|
|
hitBox: { x: 188, y: 180, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 30, nu2: 45, fw: 18, nu3: 16, bw: 17 },
|
|
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
|
|
},
|
|
ne: {
|
|
id: 'ne',
|
|
heroToken: 'ne',
|
|
nameEn: 'Necromancer',
|
|
anchorX: 301,
|
|
anchorY: 333,
|
|
hitBox: { x: 257, y: 149, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 12, nu2: 39, fw: 26, nu3: 16, bw: 15 },
|
|
defaultRates: { nu1: 3, nu2: 3, fw: 1, nu3: 2, bw: 1 },
|
|
},
|
|
ba: {
|
|
id: 'ba',
|
|
heroToken: 'ba',
|
|
nameEn: 'Barbarian',
|
|
anchorX: 400,
|
|
anchorY: 330,
|
|
hitBox: { x: 356, y: 146, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 16, nu2: 51, fw: 32, nu3: 16, bw: 17 },
|
|
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
|
|
},
|
|
pa: {
|
|
id: 'pa',
|
|
heroToken: 'pa',
|
|
nameEn: 'Paladin',
|
|
anchorX: 521,
|
|
anchorY: 339,
|
|
hitBox: { x: 477, y: 155, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 19, nu2: 65, fw: 28, nu3: 13, bw: 15 },
|
|
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
|
|
},
|
|
so: {
|
|
id: 'so',
|
|
heroToken: 'so',
|
|
nameEn: 'Sorceress',
|
|
anchorX: 626,
|
|
anchorY: 353,
|
|
hitBox: { x: 582, y: 169, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 18, nu2: 52, fw: 28, nu3: 16, bw: 16 },
|
|
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 1, bw: 1 },
|
|
},
|
|
dz: {
|
|
id: 'dz',
|
|
heroToken: 'dz',
|
|
nameEn: 'Druid',
|
|
anchorX: 720,
|
|
anchorY: 370,
|
|
hitBox: { x: 676, y: 186, w: 88, h: 184 },
|
|
defaultFrames: { nu1: 26, nu2: 53, fw: 25, nu3: 16, bw: 17 },
|
|
defaultRates: { nu1: 2, nu2: 2, fw: 1, nu3: 2, bw: 1 },
|
|
},
|
|
}
|
|
|
|
/**
|
|
* 1.13c `D2Launch.dll` `@ 0x6fa60c08` campfire per-class, per-phase rate table (`durMs = rate * 40ms`).
|
|
*/
|
|
export const CAMPFIRE_ANIM_RATES: Readonly<
|
|
Record<CampfireClassId, Readonly<Record<CampfireAnimPhase, number>>>
|
|
> = {
|
|
am: CAMPFIRE_CLASS_SPECS.am.defaultRates,
|
|
as: CAMPFIRE_CLASS_SPECS.as.defaultRates,
|
|
ne: CAMPFIRE_CLASS_SPECS.ne.defaultRates,
|
|
ba: CAMPFIRE_CLASS_SPECS.ba.defaultRates,
|
|
pa: CAMPFIRE_CLASS_SPECS.pa.defaultRates,
|
|
so: CAMPFIRE_CLASS_SPECS.so.defaultRates,
|
|
dz: CAMPFIRE_CLASS_SPECS.dz.defaultRates,
|
|
}
|
|
|
|
/**
|
|
* 1.13c Town Neutral (`TN` `HTH`) COF `frameCount` (`framesPerDir`) and 8.8 fixed-point `animRate` values
|
|
* (`D2Win.dll` `@ 0x6f8ec48b`, `0x6f8ee836`):
|
|
* - `am`, `so`, `ne`, `ba`: `80` (16 frames, ~7.8125 fps)
|
|
* - `pa`: `128` (16 frames, 12.5 fps)
|
|
* - `dz`: `96` (10 frames, 9.375 fps)
|
|
* - `ai` (`as`): `64` (12 frames, 6.25 fps)
|
|
*/
|
|
export const HERO_TN_ANIM_SPECS: Readonly<
|
|
Record<HeroToken, { readonly frameCount: number; readonly animRate: number }>
|
|
> = {
|
|
am: { frameCount: 16, animRate: 80 },
|
|
ai: { frameCount: 12, animRate: 64 },
|
|
ne: { frameCount: 16, animRate: 80 },
|
|
ba: { frameCount: 16, animRate: 80 },
|
|
pa: { frameCount: 16, animRate: 128 },
|
|
so: { frameCount: 16, animRate: 80 },
|
|
dz: { frameCount: 10, animRate: 96 },
|
|
}
|
|
|
|
export function createInitialHeroAvatarAnims(): Record<HeroToken, HeroAvatarAnimState> {
|
|
const avatarAnims = {} as Record<HeroToken, HeroAvatarAnimState>
|
|
for (const token of HERO_TOKENS) {
|
|
const spec = HERO_TN_ANIM_SPECS[token]
|
|
avatarAnims[token] = {
|
|
heroToken: token,
|
|
frameCount: spec.frameCount,
|
|
animRate: spec.animRate,
|
|
acc: 0,
|
|
frameIndex: 0,
|
|
}
|
|
}
|
|
return avatarAnims
|
|
}
|
|
|
|
export function resetHeroAvatarAnims(state: BnetFlowState): void {
|
|
for (const token of HERO_TOKENS) {
|
|
const anim = state.avatarAnims[token]
|
|
anim.acc = 0
|
|
anim.frameIndex = 0
|
|
}
|
|
}
|
|
|
|
export function createInitialFlowState(initialRoster: RosterCharacter[] = []): BnetFlowState {
|
|
const campfireClasses = {} as Record<CampfireClassId, CampfireClassSlotState>
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const spec = CAMPFIRE_CLASS_SPECS[id]
|
|
campfireClasses[id] = {
|
|
id: spec.id,
|
|
heroToken: spec.heroToken,
|
|
nameEn: spec.nameEn,
|
|
anchorX: spec.anchorX,
|
|
anchorY: spec.anchorY,
|
|
hitBox: spec.hitBox,
|
|
phase: 'nu1',
|
|
frameIndex: 0,
|
|
phaseTicks: 0,
|
|
frameCount: { ...spec.defaultFrames },
|
|
frameRate: { ...spec.defaultRates },
|
|
}
|
|
}
|
|
|
|
return {
|
|
stage: 'main_menu',
|
|
gateway: 'U.S. East',
|
|
stageTicks: 0,
|
|
globalTick: 0,
|
|
tickAccumulatorMs: 0,
|
|
lastFrameTimeMs: null,
|
|
|
|
// No prefilled credentials: the user (or a configured bot) must type them.
|
|
accountName: '',
|
|
accountPassword: '',
|
|
loginError: null,
|
|
|
|
campfireClasses,
|
|
hoveredClass: null,
|
|
selectedClass: null,
|
|
charName: '',
|
|
isExpansion: true,
|
|
isHardcore: false,
|
|
isLadder: true,
|
|
charCreateError: null,
|
|
|
|
roster: [...initialRoster],
|
|
avatarAnims: createInitialHeroAvatarAnims(),
|
|
selectedSlotIndex: 0,
|
|
deleteConfirmOpen: false,
|
|
|
|
lobbySubPanel: 'create',
|
|
gemActivated: false,
|
|
chatMessages: [
|
|
'Welcome to Battle.net!',
|
|
'This server is hosted by U.S. West Realm.',
|
|
'You have entered chat channel: Diablo II Retail_USA-1',
|
|
],
|
|
chatInput: '',
|
|
gameName: '',
|
|
gamePassword: '',
|
|
gameDescription: '',
|
|
maxPlayers: '8',
|
|
charDifferenceEnabled: false,
|
|
charDifference: '99',
|
|
difficulty: 'normal',
|
|
createGameError: null,
|
|
realmGames: [],
|
|
selectedGameName: '',
|
|
lastError: null,
|
|
statusText: '',
|
|
|
|
doorFrame: 0,
|
|
sceneReady: false,
|
|
|
|
activeInput: null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate character name per D2Launch.dll rules (2-15 ASCII letters/digits/hyphen/underscore,
|
|
* cannot start or end with hyphen/underscore or contain consecutive hyphens).
|
|
*/
|
|
export function isValidCharacterName(name: string): boolean {
|
|
const trimmed = name.trim()
|
|
if (trimmed.length < 2 || trimmed.length > 15) return false
|
|
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(trimmed)) return false
|
|
if (trimmed.endsWith('-') || trimmed.endsWith('_')) return false
|
|
if (trimmed.includes('--') || trimmed.includes('__')) return false
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Click `BATTLE.NET` on Stage 1 Main Menu -> enters `bnet_connecting`.
|
|
*/
|
|
export function clickBattleNetButton(state: BnetFlowState): void {
|
|
if (state.stage !== 'main_menu') return
|
|
state.stage = 'bnet_connecting'
|
|
state.stageTicks = 0
|
|
state.activeInput = null
|
|
}
|
|
|
|
/**
|
|
* Cancel connecting modal -> returns to `main_menu`.
|
|
*/
|
|
export function cancelConnecting(state: BnetFlowState): void {
|
|
if (state.stage !== 'bnet_connecting') return
|
|
state.stage = 'main_menu'
|
|
state.stageTicks = 0
|
|
}
|
|
|
|
/**
|
|
* Mock Battle.net authentication: any username and password entered by the user succeeds!
|
|
* If the user's character roster is empty, transitions directly to Stage 3 (`char_create`);
|
|
* otherwise transitions to Stage 4 (`char_select`).
|
|
*/
|
|
export function submitBattleNetLogin(state: BnetFlowState): boolean {
|
|
if (state.stage !== 'bnet_login') return false
|
|
if (!state.accountName.trim()) {
|
|
state.loginError = 'Account name required'
|
|
return false
|
|
}
|
|
state.loginError = null
|
|
state.stageTicks = 0
|
|
if (state.roster.length === 0) {
|
|
enterCharCreate(state)
|
|
} else {
|
|
state.stage = 'char_select'
|
|
state.selectedSlotIndex = 0
|
|
state.activeInput = null
|
|
resetHeroAvatarAnims(state)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Enter Stage 3 (`char_create`) and reset all 7 classes to campfire back idle (`nu1`).
|
|
*/
|
|
export function enterCharCreate(state: BnetFlowState): void {
|
|
state.stage = 'char_create'
|
|
state.stageTicks = 0
|
|
state.hoveredClass = null
|
|
state.selectedClass = null
|
|
state.charName = ''
|
|
state.isExpansion = true
|
|
state.isHardcore = false
|
|
state.isLadder = true
|
|
state.charCreateError = null
|
|
state.activeInput = null
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const slot = state.campfireClasses[id]
|
|
slot.phase = 'nu1'
|
|
slot.frameIndex = 0
|
|
slot.phaseTicks = 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hover a class at the campfire (`nu1` <-> `nu2`).
|
|
*/
|
|
export function hoverCampfireClass(state: BnetFlowState, classId: CampfireClassId | null): void {
|
|
if (state.stage !== 'char_create') return
|
|
const prev = state.hoveredClass
|
|
if (prev === classId) return
|
|
if (prev && state.campfireClasses[prev].phase === 'nu2') {
|
|
state.campfireClasses[prev].phase = 'nu1'
|
|
state.campfireClasses[prev].frameIndex = 0
|
|
state.campfireClasses[prev].phaseTicks = 0
|
|
}
|
|
state.hoveredClass = classId
|
|
if (classId && state.campfireClasses[classId].phase === 'nu1') {
|
|
state.campfireClasses[classId].phase = 'nu2'
|
|
state.campfireClasses[classId].frameIndex = 0
|
|
state.campfireClasses[classId].phaseTicks = 0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Click one of the 7 classes at the campfire (`D2Launch.dll` @ `0x6fa5d780`):
|
|
* - Previously selected class (if in `fw` or `nu3`) walks back (`bw` -> `nu1`)
|
|
* - Newly clicked class walks forward (`fw` -> `nu3`) and focuses the `charName` input box!
|
|
*/
|
|
export function selectCampfireClass(state: BnetFlowState, classId: CampfireClassId): void {
|
|
if (state.stage !== 'char_create') return
|
|
const prev = state.selectedClass
|
|
if (prev === classId) {
|
|
state.activeInput = 'charName'
|
|
return
|
|
}
|
|
if (prev) {
|
|
const prevSlot = state.campfireClasses[prev]
|
|
if (prevSlot.phase === 'fw' || prevSlot.phase === 'nu3') {
|
|
prevSlot.phase = 'bw'
|
|
prevSlot.frameIndex = 0
|
|
prevSlot.phaseTicks = 0
|
|
} else {
|
|
prevSlot.phase = 'nu1'
|
|
prevSlot.frameIndex = 0
|
|
prevSlot.phaseTicks = 0
|
|
}
|
|
}
|
|
state.selectedClass = classId
|
|
state.charCreateError = null
|
|
const nextSlot = state.campfireClasses[classId]
|
|
nextSlot.phase = 'fw'
|
|
nextSlot.frameIndex = 0
|
|
nextSlot.phaseTicks = 0
|
|
state.activeInput = 'charName'
|
|
}
|
|
|
|
/**
|
|
* Confirm Character Creation (`OK` button on Stage 3):
|
|
* Validates class selection & character name, adds the new character to `state.roster`,
|
|
* and transitions to Stage 4 (`char_select`) with the newly created hero selected in Slot 0.
|
|
*/
|
|
export function submitCharacterCreate(state: BnetFlowState): RosterCharacter | null {
|
|
if (state.stage !== 'char_create') return null
|
|
if (!state.selectedClass) {
|
|
state.charCreateError = 'Please select a hero class.'
|
|
return null
|
|
}
|
|
if (!isValidCharacterName(state.charName)) {
|
|
state.charCreateError = 'Character name must be 2-15 letters.'
|
|
return null
|
|
}
|
|
const spec = CAMPFIRE_CLASS_SPECS[state.selectedClass]
|
|
const trimmedName = state.charName.trim()
|
|
const newChar: RosterCharacter = {
|
|
id: `${spec.heroToken}-${Date.now()}`,
|
|
name: trimmedName,
|
|
classId: spec.id,
|
|
heroToken: spec.heroToken,
|
|
className: spec.nameEn,
|
|
level: 1,
|
|
isExpansion: state.isExpansion,
|
|
isHardcore: state.isHardcore,
|
|
isLadder: state.isLadder,
|
|
progression: 0,
|
|
maxDifficulty: 'normal',
|
|
createdAt: Date.now(),
|
|
}
|
|
state.roster = [newChar, ...state.roster].slice(0, 8)
|
|
state.selectedSlotIndex = 0
|
|
state.stage = 'char_select'
|
|
state.stageTicks = 0
|
|
state.activeInput = null
|
|
resetHeroAvatarAnims(state)
|
|
return newChar
|
|
}
|
|
|
|
/**
|
|
* Determine the highest unlocked difficulty for a character per 1.13c `D2Multi.dll` (`0x6f9e5182..0x6f9e52cd`):
|
|
* - Expansion (`isExpansion === true`, 5 acts/difficulty):
|
|
* - `progression >= 10` -> `'hell'`
|
|
* - `progression >= 5` -> `'nightmare'`
|
|
* - otherwise -> `'normal'`
|
|
* - Classic (`isExpansion === false`, 4 acts/difficulty):
|
|
* - `progression >= 8` -> `'hell'`
|
|
* - `progression >= 4` -> `'nightmare'`
|
|
* - otherwise -> `'normal'`
|
|
*/
|
|
export function getCharacterMaxDifficulty(
|
|
char: Pick<RosterCharacter, 'isExpansion' | 'progression' | 'maxDifficulty'> | undefined,
|
|
): GameDifficulty {
|
|
if (!char) return 'normal'
|
|
if (char.maxDifficulty) return char.maxDifficulty
|
|
const prog = char.progression ?? 0
|
|
if (char.isExpansion) {
|
|
if (prog >= 10) return 'hell'
|
|
if (prog >= 5) return 'nightmare'
|
|
return 'normal'
|
|
}
|
|
if (prog >= 8) return 'hell'
|
|
if (prog >= 4) return 'nightmare'
|
|
return 'normal'
|
|
}
|
|
|
|
export function isDifficultyUnlocked(
|
|
char: Pick<RosterCharacter, 'isExpansion' | 'progression' | 'maxDifficulty'> | undefined,
|
|
difficulty: GameDifficulty,
|
|
): boolean {
|
|
if (difficulty === 'normal') return true
|
|
const maxDiff = getCharacterMaxDifficulty(char)
|
|
if (difficulty === 'nightmare') return maxDiff === 'nightmare' || maxDiff === 'hell'
|
|
return maxDiff === 'hell'
|
|
}
|
|
|
|
/**
|
|
* Check whether the `CREATE GAME` button should be enabled per `D2Multi.dll` (`0x6f9e3af0..0x6f9e3c49`).
|
|
*/
|
|
export function canSubmitCreateGame(state: BnetFlowState): boolean {
|
|
const trimmedGame = state.gameName.trim()
|
|
if (trimmedGame.length < 1 || trimmedGame.length > 15) return false
|
|
const maxP = Number.parseInt(state.maxPlayers.trim(), 10)
|
|
if (!Number.isFinite(maxP) || maxP < 1 || maxP > 8) return false
|
|
if (state.charDifferenceEnabled) {
|
|
const diff = Number.parseInt(state.charDifference.trim(), 10)
|
|
if (!Number.isFinite(diff) || diff < 1 || diff > 99) return false
|
|
}
|
|
const selectedChar = state.roster[state.selectedSlotIndex]
|
|
if (selectedChar && !isDifficultyUnlocked(selectedChar, state.difficulty)) return false
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Confirm Character Selection (`OK` button or double-click on Stage 4):
|
|
* Transitions to Stage 5 (`bnet_lobby`) with the Create Game sub-panel open.
|
|
* Per `D2Multi.dll` (`0x6f9e4bc0` & `0x6f9e533f..0x6f9e535c`), `gameName` and `gameDescription`
|
|
* are never prefilled, and `difficulty` is clamped to the character's unlocked difficulties.
|
|
*/
|
|
export function confirmCharacterSelect(state: BnetFlowState): boolean {
|
|
if (state.stage !== 'char_select') return false
|
|
const selected = state.roster[state.selectedSlotIndex]
|
|
if (!selected) return false
|
|
state.stage = 'bnet_lobby'
|
|
state.stageTicks = 0
|
|
state.lobbySubPanel = 'create'
|
|
state.createGameError = null
|
|
if (!isDifficultyUnlocked(selected, state.difficulty)) {
|
|
state.difficulty = 'normal'
|
|
}
|
|
state.activeInput = 'gameName'
|
|
resetHeroAvatarAnims(state)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Toggle the iconic Battle.net Lobby Chat Gem (`gembutton.dc6` at `(398, 474)`).
|
|
*/
|
|
export function toggleChatGem(state: BnetFlowState): void {
|
|
if (state.stage !== 'bnet_lobby') return
|
|
state.gemActivated = !state.gemActivated
|
|
state.chatMessages.push(state.gemActivated ? 'Gem Activated' : 'Gem Deactivated')
|
|
if (state.chatMessages.length > 14) {
|
|
state.chatMessages.shift()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Click `CREATE GAME` on Stage 5 Battle.net Lobby -> transitions to Stage 6 (`door_loading`).
|
|
*/
|
|
export function submitCreateGame(state: BnetFlowState): boolean {
|
|
if (state.stage !== 'bnet_lobby') return false
|
|
const trimmedGame = state.gameName.trim()
|
|
if (trimmedGame.length < 1 || trimmedGame.length > 15) {
|
|
state.createGameError = 'Please enter a valid Game Name (1-15 characters).'
|
|
return false
|
|
}
|
|
const maxP = Number.parseInt(state.maxPlayers.trim(), 10)
|
|
if (!Number.isFinite(maxP) || maxP < 1 || maxP > 8) {
|
|
state.createGameError = 'Max Number of Players must be between 1 and 8.'
|
|
return false
|
|
}
|
|
if (state.charDifferenceEnabled) {
|
|
const diff = Number.parseInt(state.charDifference.trim(), 10)
|
|
if (!Number.isFinite(diff) || diff < 1 || diff > 99) {
|
|
state.createGameError = 'Character Difference must be between 1 and 99.'
|
|
return false
|
|
}
|
|
state.charDifference = String(diff)
|
|
}
|
|
const selectedChar = state.roster[state.selectedSlotIndex]
|
|
if (selectedChar && !isDifficultyUnlocked(selectedChar, state.difficulty)) {
|
|
state.createGameError = 'Selected difficulty is not unlocked for this character.'
|
|
return false
|
|
}
|
|
state.gameName = trimmedGame
|
|
state.maxPlayers = String(maxP)
|
|
state.createGameError = null
|
|
state.stage = 'door_loading'
|
|
state.stageTicks = 0
|
|
state.doorFrame = 0
|
|
state.sceneReady = false
|
|
state.activeInput = null
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Compute the current looping frame index for a `D2Win.dll` `AnimImage` control
|
|
* (`0x6f8f0a50..0x6f8f0c3a`):
|
|
* - Advances 1 frame every `rate` ticks (40ms * `rate` per frame).
|
|
* - Wraps at `framesPerDir - 1` (`0x6f8f0ac6: lea eax, [ecx - 1]; cmp [esi + 0x5c], eax; jl ...; mov [esi + 0x5c], 0`).
|
|
*/
|
|
export function getAnimImageLoopFrame(tick: number, framesPerDir: number, rate = 1): number {
|
|
const loopLen = Math.max(1, framesPerDir - 1)
|
|
const stepRate = Math.max(1, rate | 0)
|
|
return Math.floor(Math.max(0, tick) / stepRate) % loopLen
|
|
}
|
|
|
|
/**
|
|
* Advance a single Character Selection / Lobby `TN` `HTH` hero avatar by 1 tick (40ms)
|
|
* per `D2Win.dll` (`0x6f8ee81b..0x6f8ee850`):
|
|
* - `acc += animRate` (8.8 fixed-point COF speed)
|
|
* - `frame = acc >> 8`
|
|
* - If `frame >= framesPerDir - 1`, wrap `acc = 0` (`frame = 0`).
|
|
*/
|
|
export function stepHeroAvatarAnim(anim: HeroAvatarAnimState): void {
|
|
const wrapFrame = Math.max(1, anim.frameCount - 1)
|
|
const nextAcc = anim.acc + anim.animRate
|
|
const nextFrame = nextAcc >> 8
|
|
if (nextFrame >= wrapFrame) {
|
|
anim.acc = 0
|
|
anim.frameIndex = 0
|
|
} else {
|
|
anim.acc = nextAcc
|
|
anim.frameIndex = nextFrame
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advance the front-end state machine by 1 tick (25fps = 40ms per tick).
|
|
*/
|
|
export function stepFlowTick(state: BnetFlowState): void {
|
|
state.globalTick += 1
|
|
state.stageTicks += 1
|
|
|
|
for (const token of HERO_TOKENS) {
|
|
stepHeroAvatarAnim(state.avatarAnims[token])
|
|
}
|
|
|
|
if (state.stage === 'bnet_connecting') {
|
|
// After 18 ticks (~720ms), connecting modal completes and transitions to Battle.net Login
|
|
if (state.stageTicks >= 18) {
|
|
state.stage = 'bnet_login'
|
|
state.stageTicks = 0
|
|
state.activeInput = 'accountName'
|
|
}
|
|
return
|
|
}
|
|
|
|
if (state.stage === 'char_create') {
|
|
for (const id of CAMPFIRE_CLASS_ORDER) {
|
|
const slot = state.campfireClasses[id]
|
|
const count = Math.max(1, slot.frameCount[slot.phase] || 1)
|
|
const rate = Math.max(1, slot.frameRate[slot.phase] || 1)
|
|
slot.phaseTicks += 1
|
|
if (slot.phaseTicks < rate) {
|
|
continue
|
|
}
|
|
slot.phaseTicks = 0
|
|
|
|
if (slot.phase === 'fw') {
|
|
if (slot.frameIndex + 1 < count) {
|
|
slot.frameIndex += 1
|
|
} else {
|
|
slot.phase = 'nu3'
|
|
slot.frameIndex = 0
|
|
slot.phaseTicks = 0
|
|
}
|
|
} else if (slot.phase === 'bw') {
|
|
if (slot.frameIndex + 1 < count) {
|
|
slot.frameIndex += 1
|
|
} else {
|
|
slot.phase = state.hoveredClass === id ? 'nu2' : 'nu1'
|
|
slot.frameIndex = 0
|
|
slot.phaseTicks = 0
|
|
}
|
|
} else {
|
|
// Looping phases (nu1, nu2, nu3) wrap at framesPerDir - 1 per D2Win_AnimImage_Draw (0x6f8f0ac6)
|
|
const loopCount = Math.max(1, count - 1)
|
|
slot.frameIndex = (slot.frameIndex + 1) % loopCount
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (state.stage === 'door_loading') {
|
|
// Step door animation from Frame 0 to Frame 9 (every 3 ticks = ~120ms per door frame, ~1.2s total)
|
|
const targetFrame = Math.min(9, Math.floor(state.stageTicks / 3))
|
|
state.doorFrame = targetFrame
|
|
if (state.doorFrame >= 9 && state.stageTicks >= 30 && state.sceneReady) {
|
|
state.stage = 'in_game'
|
|
state.stageTicks = 0
|
|
state.tickAccumulatorMs = 0
|
|
state.lastFrameTimeMs = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const TICK_EPSILON_MS = 1e-6
|
|
|
|
/**
|
|
* Advance the front-end state machine by an elapsed frame delta `dtMs` using a fixed
|
|
* 40ms (25fps) accumulator and a bounded catch-up clamp (`FRONTEND_MAX_FRAME_DELTA_MS = 200ms`).
|
|
* Returns the number of 25fps ticks executed during this frame.
|
|
*/
|
|
export function stepFlowByDeltaMs(state: BnetFlowState, dtMs: number): number {
|
|
if (!Number.isFinite(dtMs) || dtMs <= 0) {
|
|
return 0
|
|
}
|
|
const clampedDelta = Math.min(dtMs, FRONTEND_MAX_FRAME_DELTA_MS)
|
|
state.tickAccumulatorMs = Math.min(
|
|
state.tickAccumulatorMs + clampedDelta,
|
|
FRONTEND_MAX_FRAME_DELTA_MS,
|
|
)
|
|
let steps = 0
|
|
while (
|
|
state.tickAccumulatorMs + TICK_EPSILON_MS >= FRONTEND_TICK_MS &&
|
|
steps < FRONTEND_MAX_CATCHUP_TICKS
|
|
) {
|
|
state.tickAccumulatorMs = Math.max(0, state.tickAccumulatorMs - FRONTEND_TICK_MS)
|
|
stepFlowTick(state)
|
|
steps += 1
|
|
}
|
|
return steps
|
|
}
|
|
|
|
/**
|
|
* Advance the front-end state machine from a `requestAnimationFrame` timestamp `nowMs` (in ms).
|
|
* On the first frame (`state.lastFrameTimeMs === null`), anchors `lastFrameTimeMs = nowMs` without
|
|
* advancing a tick; subsequent frames compute `nowMs - state.lastFrameTimeMs` and step fixed 40ms ticks.
|
|
*/
|
|
export function stepFlowFrame(state: BnetFlowState, nowMs: number): number {
|
|
if (!Number.isFinite(nowMs)) {
|
|
return 0
|
|
}
|
|
if (state.lastFrameTimeMs === null) {
|
|
state.lastFrameTimeMs = nowMs
|
|
return 0
|
|
}
|
|
const dtMs = nowMs - state.lastFrameTimeMs
|
|
if (dtMs <= 0) {
|
|
return 0
|
|
}
|
|
state.lastFrameTimeMs = nowMs
|
|
return stepFlowByDeltaMs(state, dtMs)
|
|
}
|
|
|
|
export const CHAR_CLASS_ID_TO_CAMPFIRE: Readonly<Record<number, CampfireClassId>> = {
|
|
0: 'am',
|
|
1: 'so',
|
|
2: 'ne',
|
|
3: 'pa',
|
|
4: 'ba',
|
|
5: 'dz',
|
|
6: 'as',
|
|
} as const
|
|
|
|
export const CAMPFIRE_TO_CHAR_CLASS_ID: Readonly<Record<CampfireClassId, 0 | 1 | 2 | 3 | 4 | 5 | 6>> = {
|
|
am: 0,
|
|
so: 1,
|
|
ne: 2,
|
|
pa: 3,
|
|
ba: 4,
|
|
dz: 5,
|
|
as: 6,
|
|
} as const
|
|
|
|
export function charSummaryToRosterCharacter(summary: {
|
|
readonly name: string
|
|
readonly charClass: number
|
|
readonly level: number
|
|
readonly expansion: boolean
|
|
readonly hardcore: boolean
|
|
readonly ladder: boolean
|
|
readonly progression?: number | undefined
|
|
readonly difficulty?: 0 | 1 | 2 | undefined
|
|
}): RosterCharacter {
|
|
const classId = CHAR_CLASS_ID_TO_CAMPFIRE[summary.charClass]
|
|
if (!classId) throw new Error(`MCP character "${summary.name}" has unknown class id ${summary.charClass}`)
|
|
const spec = CAMPFIRE_CLASS_SPECS[classId]
|
|
const progression =
|
|
summary.progression !== undefined
|
|
? summary.progression
|
|
: summary.difficulty === 2
|
|
? summary.expansion
|
|
? 10
|
|
: 8
|
|
: summary.difficulty === 1
|
|
? summary.expansion
|
|
? 5
|
|
: 4
|
|
: 0
|
|
const maxDifficulty = getCharacterMaxDifficulty({
|
|
isExpansion: summary.expansion,
|
|
progression,
|
|
})
|
|
return {
|
|
id: `${spec.heroToken}-${summary.name}`,
|
|
name: summary.name,
|
|
classId: spec.id,
|
|
heroToken: spec.heroToken,
|
|
className: spec.nameEn,
|
|
level: Math.max(1, summary.level || 1),
|
|
isExpansion: summary.expansion,
|
|
isHardcore: summary.hardcore,
|
|
isLadder: summary.ladder,
|
|
progression,
|
|
maxDifficulty,
|
|
createdAt: Date.now(),
|
|
}
|
|
}
|
|
|
|
export function applyOnlineCharactersToFlowState(
|
|
state: BnetFlowState,
|
|
characters: readonly {
|
|
readonly name: string
|
|
readonly charClass: number
|
|
readonly level: number
|
|
readonly expansion: boolean
|
|
readonly hardcore: boolean
|
|
readonly ladder: boolean
|
|
readonly progression?: number | undefined
|
|
readonly difficulty?: 0 | 1 | 2 | undefined
|
|
}[],
|
|
): void {
|
|
state.onlineConnected = true
|
|
state.roster = characters.slice(0, 8).map(charSummaryToRosterCharacter)
|
|
state.selectedSlotIndex = 0
|
|
if (state.roster.length === 0) {
|
|
enterCharCreate(state)
|
|
} else {
|
|
state.stage = 'char_select'
|
|
state.stageTicks = 0
|
|
state.activeInput = null
|
|
resetHeroAvatarAnims(state)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Battle.net session loss on any pre-game or in-game screen: return to the Battle.net login screen
|
|
* with the reason shown (1.13c D2Launch tears the realm/BNCS connections down and re-shows the
|
|
* login panel with the connection-lost message). Everything that belonged to the lost session
|
|
* (roster, game list, pending errors) is dropped; the typed account name/password stay so the user
|
|
* can log in again explicitly — nothing reconnects on its own.
|
|
*/
|
|
export function returnToLoginAfterSessionLoss(state: BnetFlowState, reason: string): void {
|
|
state.stage = 'bnet_login'
|
|
state.stageTicks = 0
|
|
state.tickAccumulatorMs = 0
|
|
state.lastFrameTimeMs = null
|
|
state.onlineConnected = false
|
|
state.loginError = reason
|
|
state.lastError = reason
|
|
state.statusText = ''
|
|
state.roster = []
|
|
state.selectedSlotIndex = 0
|
|
state.deleteConfirmOpen = false
|
|
state.charCreateError = null
|
|
state.createGameError = null
|
|
state.realmGames = []
|
|
state.selectedGameName = ''
|
|
state.doorFrame = 0
|
|
state.sceneReady = false
|
|
state.activeInput = 'accountPassword'
|
|
resetHeroAvatarAnims(state)
|
|
}
|