diablo2-web/src/client/frontend/flow-state.ts

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)
}