Fix mini-panel 8-button layout, death DC6, belt indexing/tables, and experience table (Closes #636, #637, #638, #639, #640, #641)
This commit is contained in:
parent
2d5c23a55f
commit
f1ca3dfc2b
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
|
|
@ -3,8 +3,8 @@
|
|||
*
|
||||
* Implements:
|
||||
* - 4 columns × 4 rows (16 slots) potion belt storage
|
||||
* - Bottom row (slots 1..4) always visible in the 800x600 control panel at x=423, y=561
|
||||
* - Hover or `~` key toggles full 4×4 pop-up rows (`ctrlpnl_popbelt.dc6`, 125×32 per row)
|
||||
* - Bottom row (slots 1..4) always visible in the 800x600 control panel at x=423, y=562
|
||||
* - Hover or `~` key toggles full pop-up rows (`ctrlpnl_popbelt.dc6`, 125×32 per row) based on equipped belt rows
|
||||
* - Pressing `1`–`4` or right-clicking drinks the bottom potion in that column and automatically
|
||||
* gravity-shifts upper rows down (`row 1 -> 0`, `row 2 -> 1`, `row 3 -> 2`)
|
||||
*/
|
||||
|
|
@ -17,13 +17,19 @@ import {
|
|||
BELT_BASE_X,
|
||||
BELT_BASE_Y,
|
||||
BELT_PITCH,
|
||||
BELT_PITCH_X,
|
||||
BELT_PITCH_Y,
|
||||
BELT_CELL_SIZE,
|
||||
getBeltSlotBox,
|
||||
BELT_CODE_TO_ROWS,
|
||||
getBeltRowsForCode,
|
||||
BELT_POTION_CATALOG,
|
||||
areBeltPotionsCompatible,
|
||||
itemToBeltPotion,
|
||||
isAutoBeltablePotion,
|
||||
BeltModel,
|
||||
type BeltPotionKind,
|
||||
type BeltPotionCategory,
|
||||
type BeltPotion,
|
||||
} from '../../common/items/belt-model.ts'
|
||||
|
||||
|
|
@ -33,13 +39,19 @@ export {
|
|||
BELT_BASE_X,
|
||||
BELT_BASE_Y,
|
||||
BELT_PITCH,
|
||||
BELT_PITCH_X,
|
||||
BELT_PITCH_Y,
|
||||
BELT_CELL_SIZE,
|
||||
getBeltSlotBox,
|
||||
BELT_CODE_TO_ROWS,
|
||||
getBeltRowsForCode,
|
||||
BELT_POTION_CATALOG,
|
||||
areBeltPotionsCompatible,
|
||||
itemToBeltPotion,
|
||||
isAutoBeltablePotion,
|
||||
BeltModel,
|
||||
type BeltPotionKind,
|
||||
type BeltPotionCategory,
|
||||
type BeltPotion,
|
||||
}
|
||||
|
||||
|
|
@ -53,12 +65,12 @@ export class BeltHud extends BeltModel {
|
|||
},
|
||||
font: D2FontRenderer,
|
||||
): void {
|
||||
const visibleRows = this.isExpanded ? BELT_ROWS : 1
|
||||
const visibleRows = this.isExpanded ? this.maxRows : 1
|
||||
|
||||
// Draw expanded rows 1..3 background (`ctrlpnl_popbelt.dc6`)
|
||||
// Draw expanded rows 1..maxRows-1 background (`ctrlpnl_popbelt.dc6`)
|
||||
if (visibleRows > 1) {
|
||||
for (let row = 1; row < visibleRows; row++) {
|
||||
const sy = BELT_BASE_Y - row * BELT_PITCH - 2
|
||||
const sy = BELT_BASE_Y - row * BELT_PITCH_Y - 2
|
||||
if (assets.popbeltImg) {
|
||||
ctx.drawImage(assets.popbeltImg, BELT_BASE_X - 1, sy)
|
||||
} else {
|
||||
|
|
@ -72,9 +84,10 @@ export class BeltHud extends BeltModel {
|
|||
|
||||
// Draw each cell & potion sprite
|
||||
for (let row = 0; row < visibleRows; row++) {
|
||||
const sy = BELT_BASE_Y - row * BELT_PITCH
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
const sx = BELT_BASE_X + col * BELT_PITCH
|
||||
const box = getBeltSlotBox(col, row)
|
||||
const sx = box.x
|
||||
const sy = box.y
|
||||
const pot = this.grid[row]![col]
|
||||
const isHovered = this.hoveredSlot?.row === row && this.hoveredSlot?.col === col
|
||||
|
||||
|
|
@ -90,7 +103,7 @@ export class BeltHud extends BeltModel {
|
|||
const dy = sy + Math.floor((BELT_CELL_SIZE - rect.h) / 2)
|
||||
ctx.drawImage(assets.itemsAtlasImg, rect.x, rect.y, rect.w, rect.h, dx, dy, rect.w, rect.h)
|
||||
} else {
|
||||
ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : '#9c24c8'
|
||||
ctx.fillStyle = pot.kind === 'hp' ? '#c82424' : pot.kind === 'mana' ? '#2448c8' : pot.kind === 'scroll' ? '#bfa054' : '#9c24c8'
|
||||
ctx.fillRect(sx + 6, sy + 4, 16, 20)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,25 @@
|
|||
* - Stamina Bar (x=273, y=573, w=102, h=18) with run drain & walk/stand recovery
|
||||
* - Walk / Run Toggle Button (`Panel\runbutton.dc6` at x=255, y=572, w=16, h=20, hotkey `R`)
|
||||
* - Central Mini-Panel Toggle (`Panel\menubutton.dc6` at x=393, y=562, w=15, h=24) &
|
||||
* 6-button Mini-Panel Popup (`Panel\minipanel.dc6` at x=312, y=524, w=176, h=26):
|
||||
* [Character, Inventory, Skill Tree, Automap, Quest Log, Waypoint/Menu]
|
||||
* Mini-Panel Popup (`Panel\minipanel.dc6` 8-button 176x26 at x=312, y=522 in multiplayer,
|
||||
* or `Panel\minipanel_s.dc6` 7-button 156x26 at x=322, y=522 in single-player):
|
||||
* [Character, Inventory, Skill Tree, Party Screen (mp only), Automap, Message Log, Quest Log, Game Menu (Esc)]
|
||||
* - Unallocated Stat Points & Skill Points Red `+` Buttons (`Panel\Level.dc6` + `Levelsocket.dc6`)
|
||||
*/
|
||||
|
||||
import type { D2FontRenderer } from './font.ts'
|
||||
import type { TblLang } from '../i18n/lang.ts'
|
||||
import { resolveTblKey } from '../../common/data/canonical-tbl-strings.ts'
|
||||
|
||||
export type MiniPanelAction = 'char' | 'inv' | 'skill' | 'automap' | 'quest' | 'waypoint'
|
||||
export type MiniPanelAction =
|
||||
| 'char'
|
||||
| 'inv'
|
||||
| 'skill'
|
||||
| 'party'
|
||||
| 'automap'
|
||||
| 'msg'
|
||||
| 'quest'
|
||||
| 'menu'
|
||||
|
||||
export interface ControlBarState {
|
||||
readonly level: number
|
||||
|
|
@ -34,33 +44,83 @@ export const STAMINA_BAR_BOUNDS = { x: 273, y: 573, width: 102, height: 18 } as
|
|||
export const RUN_BTN_BOUNDS = { x: 255, y: 572, width: 16, height: 20 } as const
|
||||
export const MENU_TOGGLE_BOUNDS = { x: 393, y: 562, width: 15, height: 24 } as const
|
||||
export const MINIPANEL_BOUNDS = { x: 312, y: 522, width: 176, height: 26 } as const
|
||||
export const MINIPANEL_SINGLE_BOUNDS = { x: 322, y: 522, width: 156, height: 26 } as const
|
||||
export const STAT_PLUS_BTN_BOUNDS = { x: 206, y: 558, width: 30, height: 30 } as const
|
||||
export const SKILL_PLUS_BTN_BOUNDS = { x: 565, y: 558, width: 30, height: 30 } as const
|
||||
|
||||
const MINIPANEL_ITEMS: readonly { action: MiniPanelAction; labelZh: string; labelEn: string; hotkey: string }[] = [
|
||||
{ action: 'char', labelZh: '角色属性', labelEn: 'Character', hotkey: 'A/C' },
|
||||
{ action: 'inv', labelZh: '物品栏', labelEn: 'Inventory', hotkey: 'I/B' },
|
||||
{ action: 'skill', labelZh: '技能树', labelEn: 'Skill Tree', hotkey: 'T' },
|
||||
{ action: 'automap', labelZh: '自动地图', labelEn: 'Automap', hotkey: 'Tab' },
|
||||
{ action: 'quest', labelZh: '任务日志', labelEn: 'Quest Log', hotkey: 'Q' },
|
||||
{ action: 'waypoint', labelZh: '小站/储物箱', labelEn: 'Waypoint / Stash', hotkey: 'V' },
|
||||
export interface MiniPanelItemDef {
|
||||
readonly action: MiniPanelAction
|
||||
readonly iconGroup: number
|
||||
readonly tblKey: string
|
||||
readonly hotkey?: string
|
||||
readonly fallbackEn: string
|
||||
readonly fallbackZh: string
|
||||
}
|
||||
|
||||
export const MULTIPLAYER_MINIPANEL_ITEMS: readonly MiniPanelItemDef[] = [
|
||||
{ action: 'char', iconGroup: 0, tblKey: 'minipanelchar', hotkey: 'A', fallbackEn: 'Character', fallbackZh: '人物' },
|
||||
{ action: 'inv', iconGroup: 1, tblKey: 'minipanelinv', hotkey: 'I', fallbackEn: 'Inventory', fallbackZh: '物品' },
|
||||
{ action: 'skill', iconGroup: 2, tblKey: 'minipaneltree', hotkey: 'T', fallbackEn: 'Skill Tree', fallbackZh: '技能樹' },
|
||||
{ action: 'party', iconGroup: 3, tblKey: 'minipanelparty', hotkey: 'P', fallbackEn: 'Party Screen', fallbackZh: '隊伍畫面' },
|
||||
{ action: 'automap', iconGroup: 4, tblKey: 'minipanelautomap', hotkey: 'Tab', fallbackEn: 'Automap', fallbackZh: '自動地圖' },
|
||||
{ action: 'msg', iconGroup: 5, tblKey: 'minipanelmessage', hotkey: 'M', fallbackEn: 'Message Log', fallbackZh: '訊息記錄' },
|
||||
{ action: 'quest', iconGroup: 6, tblKey: 'minipanelquest', hotkey: 'Q', fallbackEn: 'Quest Log', fallbackZh: '任務記錄' },
|
||||
{ action: 'menu', iconGroup: 7, tblKey: 'minipanelmenubtn', fallbackEn: 'Game Menu (Esc)', fallbackZh: '遊戲選單(Esc)' },
|
||||
]
|
||||
|
||||
export const SINGLEPLAYER_MINIPANEL_ITEMS: readonly MiniPanelItemDef[] = [
|
||||
{ action: 'char', iconGroup: 0, tblKey: 'minipanelchar', hotkey: 'A', fallbackEn: 'Character', fallbackZh: '人物' },
|
||||
{ action: 'inv', iconGroup: 1, tblKey: 'minipanelinv', hotkey: 'I', fallbackEn: 'Inventory', fallbackZh: '物品' },
|
||||
{ action: 'skill', iconGroup: 2, tblKey: 'minipaneltree', hotkey: 'T', fallbackEn: 'Skill Tree', fallbackZh: '技能樹' },
|
||||
{ action: 'automap', iconGroup: 4, tblKey: 'minipanelautomap', hotkey: 'Tab', fallbackEn: 'Automap', fallbackZh: '自動地圖' },
|
||||
{ action: 'msg', iconGroup: 5, tblKey: 'minipanelmessage', hotkey: 'M', fallbackEn: 'Message Log', fallbackZh: '訊息記錄' },
|
||||
{ action: 'quest', iconGroup: 6, tblKey: 'minipanelquest', hotkey: 'Q', fallbackEn: 'Quest Log', fallbackZh: '任務記錄' },
|
||||
{ action: 'menu', iconGroup: 7, tblKey: 'minipanelmenubtn', fallbackEn: 'Game Menu (Esc)', fallbackZh: '遊戲選單(Esc)' },
|
||||
]
|
||||
|
||||
export function getMiniPanelTooltip(item: MiniPanelItemDef, lang: TblLang): string {
|
||||
const base = resolveTblKey(item.tblKey, lang) ?? (lang === 'ENG' ? item.fallbackEn : item.fallbackZh)
|
||||
if (!item.hotkey) {
|
||||
return base
|
||||
}
|
||||
const specialFmt = resolveTblKey('minipanelspecial', lang) ?? (lang === 'ENG' ? ' (%s)' : '(%s)')
|
||||
return base + specialFmt.replace('%s', item.hotkey)
|
||||
}
|
||||
|
||||
export class ControlBarHud {
|
||||
miniPanelOpen = true
|
||||
lang: TblLang = 'CHI'
|
||||
isMultiplayer = true
|
||||
pressedAction: MiniPanelAction | null = null
|
||||
hoveredElement: 'exp' | 'stamina' | 'run' | 'menu' | 'statPlus' | 'skillPlus' | MiniPanelAction | null = null
|
||||
|
||||
get currentMiniPanelItems(): readonly MiniPanelItemDef[] {
|
||||
return this.isMultiplayer ? MULTIPLAYER_MINIPANEL_ITEMS : SINGLEPLAYER_MINIPANEL_ITEMS
|
||||
}
|
||||
|
||||
get currentMiniPanelBounds(): { x: number; y: number; width: number; height: number } {
|
||||
return this.isMultiplayer ? MINIPANEL_BOUNDS : MINIPANEL_SINGLE_BOUNDS
|
||||
}
|
||||
|
||||
get currentButtonStartX(): number {
|
||||
return this.isMultiplayer ? 320 : 330
|
||||
}
|
||||
|
||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||
this.hoveredElement = null
|
||||
|
||||
if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) {
|
||||
const idx = Math.min(
|
||||
MINIPANEL_ITEMS.length - 1,
|
||||
Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)),
|
||||
)
|
||||
this.hoveredElement = MINIPANEL_ITEMS[idx]!.action
|
||||
return
|
||||
if (this.miniPanelOpen) {
|
||||
const items = this.currentMiniPanelItems
|
||||
const startX = this.currentButtonStartX
|
||||
const relX = logicalX - startX
|
||||
const relY = logicalY - 525
|
||||
if (relY >= 0 && relY < 20 && relX >= 0) {
|
||||
const idx = Math.floor(relX / 20)
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
this.hoveredElement = items[idx]!.action
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.inRect(logicalX, logicalY, EXP_BAR_HOVER_BOUNDS)) {
|
||||
this.hoveredElement = 'exp'
|
||||
|
|
@ -77,6 +137,27 @@ export class ControlBarHud {
|
|||
}
|
||||
}
|
||||
|
||||
handleMouseDown(logicalX: number, logicalY: number): void {
|
||||
if (this.miniPanelOpen) {
|
||||
const items = this.currentMiniPanelItems
|
||||
const startX = this.currentButtonStartX
|
||||
const relX = logicalX - startX
|
||||
const relY = logicalY - 525
|
||||
if (relY >= 0 && relY < 20 && relX >= 0) {
|
||||
const idx = Math.floor(relX / 20)
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
this.pressedAction = items[idx]!.action
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
this.pressedAction = null
|
||||
}
|
||||
|
||||
handleMouseUp(): void {
|
||||
this.pressedAction = null
|
||||
}
|
||||
|
||||
handleClick(
|
||||
logicalX: number,
|
||||
logicalY: number,
|
||||
|
|
@ -86,13 +167,18 @@ export class ControlBarHud {
|
|||
onMiniPanelAction: (action: MiniPanelAction) => void
|
||||
},
|
||||
): boolean {
|
||||
if (this.miniPanelOpen && this.inRect(logicalX, logicalY, MINIPANEL_BOUNDS)) {
|
||||
const idx = Math.min(
|
||||
MINIPANEL_ITEMS.length - 1,
|
||||
Math.max(0, Math.floor((logicalX - (MINIPANEL_BOUNDS.x + 8)) / 27)),
|
||||
)
|
||||
callbacks.onMiniPanelAction(MINIPANEL_ITEMS[idx]!.action)
|
||||
return true
|
||||
if (this.miniPanelOpen) {
|
||||
const items = this.currentMiniPanelItems
|
||||
const startX = this.currentButtonStartX
|
||||
const relX = logicalX - startX
|
||||
const relY = logicalY - 525
|
||||
if (relY >= 0 && relY < 20 && relX >= 0) {
|
||||
const idx = Math.floor(relX / 20)
|
||||
if (idx >= 0 && idx < items.length) {
|
||||
callbacks.onMiniPanelAction(items[idx]!.action)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.inRect(logicalX, logicalY, MENU_TOGGLE_BOUNDS)) {
|
||||
this.miniPanelOpen = !this.miniPanelOpen
|
||||
|
|
@ -180,27 +266,27 @@ export class ControlBarHud {
|
|||
ctx.drawImage(assets.menuBtnImg, frameIdx * 15, 0, 15, 24, MENU_TOGGLE_BOUNDS.x, MENU_TOGGLE_BOUNDS.y, 15, 24)
|
||||
}
|
||||
|
||||
// 6. Central Mini-panel Popup (`minipanel.dc6` 176x26 + `minipanelbtn.dc6` 20x20 icons)
|
||||
// 6. Central Mini-panel Popup (`minipanel.dc6` 176x26 / `minipanel_s.dc6` 156x26 + `minipanelbtn.dc6` 20x20 icons)
|
||||
if (this.miniPanelOpen) {
|
||||
const bounds = this.currentMiniPanelBounds
|
||||
const items = this.currentMiniPanelItems
|
||||
const startX = this.currentButtonStartX
|
||||
|
||||
if (assets.miniPanelImg) {
|
||||
ctx.drawImage(assets.miniPanelImg, MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y)
|
||||
ctx.drawImage(assets.miniPanelImg, bounds.x, bounds.y)
|
||||
} else {
|
||||
ctx.fillStyle = '#241e16'
|
||||
ctx.fillRect(MINIPANEL_BOUNDS.x, MINIPANEL_BOUNDS.y, MINIPANEL_BOUNDS.width, MINIPANEL_BOUNDS.height)
|
||||
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height)
|
||||
}
|
||||
for (let i = 0; i < MINIPANEL_ITEMS.length; i++) {
|
||||
const bx = MINIPANEL_BOUNDS.x + 8 + i * 27
|
||||
const by = MINIPANEL_BOUNDS.y + 3
|
||||
const item = MINIPANEL_ITEMS[i]!
|
||||
const isHovered = this.hoveredElement === item.action
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const bx = startX + i * 20
|
||||
const by = 525
|
||||
const item = items[i]!
|
||||
const isPressed = this.pressedAction === item.action
|
||||
if (assets.miniPanelBtnsImg) {
|
||||
const frameIdx = i * 2 + (isHovered ? 1 : 0)
|
||||
const frameIdx = item.iconGroup * 2 + (isPressed ? 1 : 0)
|
||||
ctx.drawImage(assets.miniPanelBtnsImg, frameIdx * 20, 0, 20, 20, bx, by, 20, 20)
|
||||
}
|
||||
if (isHovered) {
|
||||
ctx.strokeStyle = '#e8c26b'
|
||||
ctx.strokeRect(bx - 0.5, by - 0.5, 21, 21)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,10 +352,17 @@ export class ControlBarHud {
|
|||
SKILL_PLUS_BTN_BOUNDS.x + 15,
|
||||
518,
|
||||
)
|
||||
} else if (this.hoveredElement && this.hoveredElement !== 'menu') {
|
||||
const found = MINIPANEL_ITEMS.find(m => m.action === this.hoveredElement)
|
||||
} else if (this.hoveredElement === 'menu') {
|
||||
const tip = this.miniPanelOpen
|
||||
? (resolveTblKey('panelcmini', this.lang) ?? (isEn ? 'Close Mini Panel' : '關閉迷你面板'))
|
||||
: (resolveTblKey('panelmini', this.lang) ?? (isEn ? 'Open Mini Panel' : '開啟迷你面板'))
|
||||
this.drawMiniTooltip(ctx, font, tip, MENU_TOGGLE_BOUNDS.x + 8, 518)
|
||||
} else if (this.hoveredElement) {
|
||||
const items = this.currentMiniPanelItems
|
||||
const found = items.find((m) => m.action === this.hoveredElement)
|
||||
if (found) {
|
||||
this.drawMiniTooltip(ctx, font, `${isEn ? found.labelEn : found.labelZh} (${found.hotkey})`, 400, 502)
|
||||
const tip = getMiniPanelTooltip(found, this.lang)
|
||||
this.drawMiniTooltip(ctx, font, tip, 400, 502)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1002,6 +1002,8 @@ export class HudManager {
|
|||
popbelt: `${baseUrl}/popbelt.png`,
|
||||
miniPanel: `${baseUrl}/minipanel.png`,
|
||||
miniPanelBtns: `${baseUrl}/minipanel-btns.png`,
|
||||
deathHardcore: `${baseUrl}/death-hardcore.png`,
|
||||
deathInst: `${baseUrl}/death-inst.png`,
|
||||
runBtn: `${baseUrl}/runbutton.png`,
|
||||
menuBtn: `${baseUrl}/menubutton.png`,
|
||||
buySellBtn: `${baseUrl}/buysellbtn.png`,
|
||||
|
|
@ -1698,6 +1700,7 @@ export class HudManager {
|
|||
|
||||
window.addEventListener('mouseup', () => {
|
||||
this.cursor.handleMouseUp()
|
||||
this.controlBar.handleMouseUp()
|
||||
this.worldPanels.releaseMomentaryVendorButton()
|
||||
})
|
||||
|
||||
|
|
@ -1805,18 +1808,20 @@ export class HudManager {
|
|||
const docking = this.getDockingLayout()
|
||||
const barPt = toControlBarLocal(pt.x, pt.y, docking)
|
||||
|
||||
this.controlBar.handleMouseDown(barPt.x, barPt.y)
|
||||
|
||||
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
|
||||
const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y)
|
||||
if (beltHit) {
|
||||
if (e.button === 2) {
|
||||
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
|
||||
this.drinkBeltSlot(beltHit.row * 4 + beltHit.col, Boolean(e.shiftKey))
|
||||
return
|
||||
}
|
||||
if (this._commandSink) {
|
||||
const sink = this._commandSink
|
||||
const slotIndex = beltHit.row * 4 + beltHit.col
|
||||
const cursor = this.inventory.cursorItem
|
||||
const beltPotion = this.belt.getSlot(beltHit.row, beltHit.col)
|
||||
const beltPotion = this.belt.getSlot(beltHit.col, beltHit.row)
|
||||
const rawBeltId =
|
||||
(beltPotion as { itemId?: number } | null)?.itemId ??
|
||||
(beltPotion ? Number(beltPotion.id) : NaN)
|
||||
|
|
@ -1840,7 +1845,6 @@ export class HudManager {
|
|||
return
|
||||
}
|
||||
}
|
||||
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1864,9 +1868,11 @@ export class HudManager {
|
|||
if (action === 'char') this.toggleLeftPanel('char')
|
||||
else if (action === 'inv') this.toggleRightPanel('inv')
|
||||
else if (action === 'skill') this.toggleRightPanel('skill')
|
||||
else if (action === 'party') this.toggleLeftPanel('party')
|
||||
else if (action === 'automap') this.callbacks.onToggleAutomap()
|
||||
else if (action === 'msg') (this.callbacks as any).onToggleMessageLog?.() ?? this.toggleLeftPanel('msg' as any)
|
||||
else if (action === 'quest') this.toggleLeftPanel('quest')
|
||||
else if (action === 'waypoint') this.toggleLeftPanel('waypoint')
|
||||
else if (action === 'menu') this.toggleEscapeMenu()
|
||||
},
|
||||
})
|
||||
) {
|
||||
|
|
@ -2350,6 +2356,7 @@ export class HudManager {
|
|||
}
|
||||
this.state.leftPanel = this.leftPanel
|
||||
this.state.rightPanel = this.rightPanel
|
||||
this.belt.setEquippedBelt(this.inventory.equipped.belt ?? null)
|
||||
this.state.beltExpanded = this.belt.isExpanded
|
||||
this.state.beltPotionsCount = this.belt.countTotalPotions()
|
||||
this.state.miniPanelOpen = this.controlBar.miniPanelOpen
|
||||
|
|
@ -2896,23 +2903,35 @@ export class HudManager {
|
|||
})
|
||||
}
|
||||
|
||||
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
|
||||
// 10d. Authentic Death Presentation (`youdiedhardcore.dc6` & `youdiedinst.dc6` centered at (W/2, H/2 - 94) and (W/2, H/2 - 46))
|
||||
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
|
||||
const centerX = Math.round(profile.width / 2)
|
||||
const centerY = Math.round(profile.height / 2)
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
|
||||
ctx.fillRect(0, 0, profile.width, profile.height)
|
||||
ctx.fillStyle = '#d4b468'
|
||||
ctx.font = 'bold 22px serif'
|
||||
ctx.textAlign = 'center'
|
||||
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
|
||||
ctx.fillText(deathMsg, centerX, centerY)
|
||||
this.font.drawText(ctx, deathMsg, centerX, centerY, {
|
||||
font: 'font16',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
const hardcoreImg = this.images.get('deathHardcore')
|
||||
const instImg = this.images.get('deathInst')
|
||||
const y1 = centerY - 94
|
||||
const y2 = centerY - 46
|
||||
|
||||
if (hardcoreImg) {
|
||||
ctx.drawImage(hardcoreImg, centerX - Math.round(hardcoreImg.width / 2), y1)
|
||||
} else {
|
||||
this.font.drawText(ctx, 'YOU HAVE DIED', centerX, y1 + 18, {
|
||||
font: 'fontexocet10',
|
||||
color: 'red',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
if (instImg) {
|
||||
ctx.drawImage(instImg, centerX - Math.round(instImg.width / 2), y2)
|
||||
} else {
|
||||
this.font.drawText(ctx, 'PRESS ESC TO CONTINUE.', centerX, y2 + 18, {
|
||||
font: 'font16',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
* Zero DOM or Node globals.
|
||||
*/
|
||||
|
||||
export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing'
|
||||
export type BeltPotionKind = 'hp' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll'
|
||||
|
||||
export type BeltPotionCategory = 'health' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing'
|
||||
export type BeltPotionCategory = 'health' | 'mana' | 'rejuv' | 'stamina' | 'antidote' | 'thawing' | 'scroll'
|
||||
|
||||
export interface BeltPotion {
|
||||
readonly id: string
|
||||
|
|
@ -24,12 +24,60 @@ export interface BeltPotion {
|
|||
export const BELT_COLS = 4
|
||||
export const BELT_ROWS = 4
|
||||
export const BELT_BASE_X = 423
|
||||
export const BELT_BASE_Y = 561
|
||||
export const BELT_BASE_Y = 562
|
||||
export const BELT_PITCH = 31
|
||||
export const BELT_PITCH_X = 31
|
||||
export const BELT_PITCH_Y = 32
|
||||
export const BELT_CELL_SIZE = 29
|
||||
|
||||
export function getBeltSlotBox(col: number, row: number): { x: number; y: number; width: number; height: number } {
|
||||
return {
|
||||
x: BELT_BASE_X + col * BELT_PITCH_X,
|
||||
y: BELT_BASE_Y - row * BELT_PITCH_Y,
|
||||
width: BELT_CELL_SIZE,
|
||||
height: BELT_CELL_SIZE,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative Diablo II v1.13c `misc.txt` belt-compatible potion definitions (`beltable = 1`).
|
||||
* Rows granted by equipped belt item code per 1.13c `Armor.txt` + `Belts.txt`.
|
||||
* Default (no belt) has 1 row (4 boxes).
|
||||
*/
|
||||
export const BELT_CODE_TO_ROWS: Readonly<Record<string, number>> = {
|
||||
// Normal belts
|
||||
lbl: 2, // Sash (8 boxes)
|
||||
vbl: 2, // Light Belt (8 boxes)
|
||||
mbl: 3, // Belt (12 boxes)
|
||||
tbl: 3, // Heavy Belt (12 boxes)
|
||||
hbl: 4, // Plated Belt / Girdle (16 boxes)
|
||||
// Exceptional belts
|
||||
zlb: 4, // Demonhide Sash (16 boxes)
|
||||
zvb: 4, // Sharkskin Belt (16 boxes)
|
||||
zmb: 4, // Mesh Belt (16 boxes)
|
||||
ztb: 4, // Battle Belt (16 boxes)
|
||||
zhb: 4, // War Belt (16 boxes)
|
||||
// Elite belts
|
||||
ulc: 4, // Spiderweb Sash (16 boxes)
|
||||
uvc: 4, // Vampirefang Belt (16 boxes)
|
||||
umc: 4, // Mithril Coil (16 boxes)
|
||||
utc: 4, // Troll Belt (16 boxes)
|
||||
uhc: 4, // Colossus Girdle (16 boxes)
|
||||
}
|
||||
|
||||
export function getBeltRowsForCode(beltCode: string | null | undefined): number {
|
||||
if (!beltCode) return 1
|
||||
const code = beltCode.trim().toLowerCase()
|
||||
return BELT_CODE_TO_ROWS[code] ?? 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative Diablo II v1.13c `misc.txt` belt-compatible potion & scroll definitions (`belt = 1`).
|
||||
* Icon filenames (invfile) strictly match misc.txt definitions:
|
||||
* - vps (Stamina): invwps
|
||||
* - yps (Antidote): invnps
|
||||
* - wms (Thawing): invyps
|
||||
* - tsc (Town Portal Scroll): invbsc
|
||||
* - isc (Identify Scroll): invrsc
|
||||
*/
|
||||
export const BELT_POTION_CATALOG: Readonly<Record<string, Omit<BeltPotion, 'id'>>> = {
|
||||
hp1: { code: 'hp1', invFile: 'invhp1', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 },
|
||||
|
|
@ -44,48 +92,60 @@ export const BELT_POTION_CATALOG: Readonly<Record<string, Omit<BeltPotion, 'id'>
|
|||
mp5: { code: 'mp5', invFile: 'invmp5', name: 'Super Mana Potion', nameZh: '超级法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 250 },
|
||||
rvs: { code: 'rvs', invFile: 'invvps', name: 'Rejuvenation Potion', nameZh: '回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 35, healMana: 35 },
|
||||
rvl: { code: 'rvl', invFile: 'invvpl', name: 'Full Rejuvenation Potion', nameZh: '全面回复活力药剂', kind: 'rejuv', category: 'rejuv', healHp: 100, healMana: 100 },
|
||||
vps: { code: 'vps', invFile: 'invvps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', category: 'stamina', healHp: 0, healMana: 0 },
|
||||
yps: { code: 'yps', invFile: 'invyps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', category: 'antidote', healHp: 0, healMana: 0 },
|
||||
wms: { code: 'wms', invFile: 'invwms', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', category: 'thawing', healHp: 0, healMana: 0 },
|
||||
vps: { code: 'vps', invFile: 'invwps', name: 'Stamina Potion', nameZh: '耐力药剂', kind: 'stamina', category: 'stamina', healHp: 0, healMana: 0 },
|
||||
yps: { code: 'yps', invFile: 'invnps', name: 'Antidote Potion', nameZh: '解毒药剂', kind: 'antidote', category: 'antidote', healHp: 0, healMana: 0 },
|
||||
wms: { code: 'wms', invFile: 'invyps', name: 'Thawing Potion', nameZh: '融冰药剂', kind: 'thawing', category: 'thawing', healHp: 0, healMana: 0 },
|
||||
// Classic potions (1.13c misc.txt)
|
||||
hpo: { code: 'hpo', invFile: 'invhpo', name: 'Minor Healing Potion', nameZh: '轻微治疗药剂', kind: 'hp', category: 'health', healHp: 30, healMana: 0 },
|
||||
mpo: { code: 'mpo', invFile: 'invmpo', name: 'Minor Mana Potion', nameZh: '轻微法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 20 },
|
||||
hpf: { code: 'hpf', invFile: 'invhpf', name: 'Healing Potion', nameZh: '治疗药剂', kind: 'hp', category: 'health', healHp: 100, healMana: 0 },
|
||||
mpf: { code: 'mpf', invFile: 'invmpf', name: 'Mana Potion', nameZh: '法力药剂', kind: 'mana', category: 'mana', healHp: 0, healMana: 80 },
|
||||
// Scrolls with belt=1 in Misc.txt
|
||||
tsc: { code: 'tsc', invFile: 'invbsc', name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 },
|
||||
isc: { code: 'isc', invFile: 'invrsc', name: 'Scroll of Identify', nameZh: '辨识卷轴', kind: 'scroll', category: 'scroll', healHp: 0, healMana: 0 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether two belt potions belong to the same column family per `D2Common.dll` `BELT_CheckPotionType`.
|
||||
* Checks whether two belt items belong to the same column family per `D2Common.dll` `BELT_CheckPotionType`.
|
||||
* Scrolls require identical item code; potions match by kind family.
|
||||
*/
|
||||
export function areBeltPotionsCompatible(a: BeltPotion, b: BeltPotion): boolean {
|
||||
if (a.kind === 'scroll' || b.kind === 'scroll') {
|
||||
return a.code === b.code
|
||||
}
|
||||
return a.kind === b.kind
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw `Item`, `UiInventoryItem`, `BeltPotion`, or item-like object into a `BeltPotion`
|
||||
* if and only if it is an authentic belt-compatible potion (`hp1`–`hp5`, `mp1`–`mp5`, `rvs`, `rvl`, `vps`, `yps`, `wms`).
|
||||
* if and only if it is an authentic belt-compatible item (potions and scrolls with `belt = 1`).
|
||||
* Excludes throwing potions (`gpm`, `opm`, `gpl`, `opl`, `gps`, `ops` which are weapons with `beltable=0`).
|
||||
*/
|
||||
export function itemToBeltPotion(item: unknown): BeltPotion | null {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const raw = item as Record<string, any>
|
||||
|
||||
// Unwrap GroundItemEntity if passed directly
|
||||
// Unwrap GroundItemEntity or nested item object
|
||||
const inner = raw.item && typeof raw.item === 'object' && !raw.kind ? (raw.item as Record<string, any>) : raw
|
||||
|
||||
// Already a valid BeltPotion
|
||||
if (
|
||||
typeof inner.kind === 'string' &&
|
||||
['hp', 'mana', 'rejuv', 'stamina', 'antidote', 'thawing'].includes(inner.kind) &&
|
||||
typeof inner.healHp === 'number' &&
|
||||
typeof inner.healMana === 'number'
|
||||
['hp', 'mana', 'rejuv', 'stamina', 'antidote', 'thawing', 'scroll'].includes(inner.kind) &&
|
||||
typeof inner.code === 'string'
|
||||
) {
|
||||
const kind = inner.kind as BeltPotionKind
|
||||
const code = inner.code.trim().toLowerCase()
|
||||
const catalogEntry = BELT_POTION_CATALOG[code]
|
||||
return {
|
||||
id: typeof inner.id === 'string' ? inner.id : `pot-${inner.code ?? inner.kind}`,
|
||||
code: typeof inner.code === 'string' ? inner.code.trim().toLowerCase() : inner.kind,
|
||||
invFile: typeof inner.invFile === 'string' ? inner.invFile : `inv${inner.code ?? 'hp4'}`,
|
||||
name: typeof inner.name === 'string' ? inner.name : 'Potion',
|
||||
nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (typeof inner.name === 'string' ? inner.name : '药剂'),
|
||||
kind,
|
||||
category: kind === 'hp' ? 'health' : kind,
|
||||
healHp: inner.healHp,
|
||||
healMana: inner.healMana,
|
||||
id: typeof inner.id === 'string' ? inner.id : `pot-${code}`,
|
||||
code,
|
||||
invFile: inner.invFile || catalogEntry?.invFile || `inv${code}`,
|
||||
name: typeof inner.name === 'string' ? inner.name : (catalogEntry?.name ?? 'Potion'),
|
||||
nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (catalogEntry?.nameZh ?? inner.name ?? '药剂'),
|
||||
kind: inner.kind as BeltPotionKind,
|
||||
category: inner.category ?? catalogEntry?.category ?? (inner.kind === 'hp' ? 'health' : (inner.kind as BeltPotionCategory)),
|
||||
healHp: typeof inner.healHp === 'number' ? inner.healHp : (catalogEntry?.healHp ?? 0),
|
||||
healMana: typeof inner.healMana === 'number' ? inner.healMana : (catalogEntry?.healMana ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,13 +156,13 @@ export function itemToBeltPotion(item: unknown): BeltPotion | null {
|
|||
}
|
||||
|
||||
const rawCode = String(inner.code ?? base?.id ?? '').trim().toLowerCase()
|
||||
if (['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops'].includes(rawCode)) {
|
||||
if (!rawCode || ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops'].includes(rawCode)) {
|
||||
return null
|
||||
}
|
||||
const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode || 'item'}`)
|
||||
|
||||
const catalogEntry = rawCode ? BELT_POTION_CATALOG[rawCode] : undefined
|
||||
const catalogEntry = BELT_POTION_CATALOG[rawCode]
|
||||
if (catalogEntry) {
|
||||
const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`)
|
||||
return {
|
||||
...catalogEntry,
|
||||
id: idStr,
|
||||
|
|
@ -112,51 +172,23 @@ export function itemToBeltPotion(item: unknown): BeltPotion | null {
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback for synthetic engine potion items (e.g. base.id === 'potion' or base.tags includes 'potion')
|
||||
const baseId = String(base?.id ?? '').trim().toLowerCase()
|
||||
const baseType = String(base?.type ?? '').trim().toLowerCase()
|
||||
const tags: readonly string[] = Array.isArray(base?.tags) ? base.tags : []
|
||||
const nameLower = `${inner.name ?? ''} ${inner.nameZh ?? ''} ${base?.name ?? ''}`.toLowerCase()
|
||||
|
||||
const isTaggedPotion =
|
||||
baseId === 'potion' ||
|
||||
['hpot', 'mpot', 'rpot', 'spot', 'apot', 'wpot', 'pot'].includes(baseType) ||
|
||||
tags.some(t => ['potion', 'hpot', 'mpot', 'rpot', 'healing'].includes(String(t).toLowerCase()))
|
||||
|
||||
// Guard against non-beltable items like elixirs or throwing potions
|
||||
if (!isTaggedPotion) {
|
||||
if (!/\b(healing|mana|rejuvenation|stamina|antidote|thawing)\s+potion\b/i.test(nameLower) &&
|
||||
!/(治疗药剂|法力药剂|回复活力药剂|耐力药剂|解毒药剂|融冰药剂)/.test(nameLower)) {
|
||||
return null
|
||||
// Check if item has explicit beltable property from misc.txt
|
||||
if (inner.beltable === 1 || inner.belt === 1 || base?.beltable === 1 || base?.belt === 1) {
|
||||
const idStr = String(inner.id ?? raw.id ?? `pot-${rawCode}`)
|
||||
return {
|
||||
id: idStr,
|
||||
code: rawCode,
|
||||
invFile: typeof inner.invFile === 'string' && inner.invFile.length > 0 ? inner.invFile : (base?.invFile ?? `inv${rawCode}`),
|
||||
name: typeof inner.name === 'string' ? inner.name : (base?.name ?? rawCode),
|
||||
nameZh: typeof inner.nameZh === 'string' ? inner.nameZh : (base?.nameZh ?? inner.name ?? rawCode),
|
||||
kind: 'hp',
|
||||
category: 'health',
|
||||
healHp: 0,
|
||||
healMana: 0,
|
||||
}
|
||||
}
|
||||
if (/strangling|choking|rancid|fulminating|exploding|oil\s+potion/i.test(nameLower)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let template = BELT_POTION_CATALOG.hp3!
|
||||
if (baseType === 'rpot' || nameLower.includes('rejuv') || nameLower.includes('活力')) {
|
||||
template = nameLower.includes('full') || nameLower.includes('全面') ? BELT_POTION_CATALOG.rvl! : BELT_POTION_CATALOG.rvs!
|
||||
} else if (baseType === 'mpot' || nameLower.includes('mana') || nameLower.includes('法力')) {
|
||||
template = BELT_POTION_CATALOG.mp3!
|
||||
} else if (baseType === 'spot' || nameLower.includes('stamina') || nameLower.includes('耐力')) {
|
||||
template = BELT_POTION_CATALOG.vps!
|
||||
} else if (baseType === 'apot' || nameLower.includes('antidote') || nameLower.includes('解毒')) {
|
||||
template = BELT_POTION_CATALOG.yps!
|
||||
} else if (baseType === 'wpot' || nameLower.includes('thawing') || nameLower.includes('融冰')) {
|
||||
template = BELT_POTION_CATALOG.wms!
|
||||
}
|
||||
|
||||
const statHeal = typeof inner.stats?.heal === 'number' ? inner.stats.heal : (typeof base?.stats?.heal === 'number' ? base.stats.heal : undefined)
|
||||
return {
|
||||
...template,
|
||||
id: idStr,
|
||||
code: rawCode || template.code,
|
||||
invFile: typeof inner.invFile === 'string' && inner.invFile.length > 0 ? inner.invFile : template.invFile,
|
||||
name: typeof inner.name === 'string' && inner.name.length > 0 ? inner.name : template.name,
|
||||
nameZh: typeof inner.nameZh === 'string' && inner.nameZh.length > 0 ? inner.nameZh : template.nameZh,
|
||||
healHp: template.kind === 'hp' && statHeal !== undefined ? statHeal : template.healHp,
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function isAutoBeltablePotion(item: unknown): boolean {
|
||||
|
|
@ -175,8 +207,35 @@ export class BeltModel {
|
|||
/** Currently hovered slot `(row, col)`. */
|
||||
hoveredSlot: { row: number; col: number } | null = null
|
||||
|
||||
private _equippedBeltCode: string | null = null
|
||||
private _hasEquippedBelt = false
|
||||
|
||||
get maxRows(): number {
|
||||
if (!this._hasEquippedBelt) return 4
|
||||
return getBeltRowsForCode(this._equippedBeltCode)
|
||||
}
|
||||
|
||||
setEquippedBelt(beltItemOrCode: unknown | string | null): void {
|
||||
this._hasEquippedBelt = true
|
||||
if (beltItemOrCode === null || beltItemOrCode === undefined) {
|
||||
this._equippedBeltCode = null
|
||||
return
|
||||
}
|
||||
if (typeof beltItemOrCode === 'string') {
|
||||
this._equippedBeltCode = beltItemOrCode.trim().toLowerCase()
|
||||
} else {
|
||||
const item = beltItemOrCode as Record<string, any>
|
||||
const code = item.code ?? item.base?.id ?? item.baseId ?? item.id
|
||||
this._equippedBeltCode = typeof code === 'string' ? code.trim().toLowerCase() : null
|
||||
}
|
||||
}
|
||||
|
||||
get isExpanded(): boolean {
|
||||
return this.lockedOpen || this.hovered
|
||||
return (this.lockedOpen || this.hovered) && this.maxRows > 1
|
||||
}
|
||||
|
||||
get activeRows(): number {
|
||||
return this.isExpanded ? this.maxRows : 1
|
||||
}
|
||||
|
||||
togglePopup(): void {
|
||||
|
|
@ -187,7 +246,7 @@ export class BeltModel {
|
|||
* Drink the potion in column `col` (0..3) at `row` (default 0) and shift upper rows down.
|
||||
*/
|
||||
useSlot(col: number, row = 0): BeltPotion | null {
|
||||
if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return null
|
||||
if (col < 0 || col >= BELT_COLS || row < 0 || row >= this.maxRows) return null
|
||||
const consumed = this.grid[row]?.[col] ?? null
|
||||
if (!consumed) return null
|
||||
for (let r = row; r < BELT_ROWS - 1; r++) {
|
||||
|
|
@ -202,12 +261,13 @@ export class BeltModel {
|
|||
*/
|
||||
placePotion(potion: BeltPotion, col: number, row?: number): BeltPotion | null {
|
||||
if (col < 0 || col >= BELT_COLS) return potion
|
||||
if (row !== undefined && row >= 0 && row < BELT_ROWS) {
|
||||
if (row !== undefined) {
|
||||
if (row < 0 || row >= this.maxRows) return potion
|
||||
const prev = this.grid[row]![col] ?? null
|
||||
this.grid[row]![col] = potion
|
||||
return prev
|
||||
}
|
||||
for (let r = 0; r < BELT_ROWS; r++) {
|
||||
for (let r = 0; r < this.maxRows; r++) {
|
||||
if (this.grid[r]![col] === null) {
|
||||
this.grid[r]![col] = potion
|
||||
return null
|
||||
|
|
@ -242,22 +302,13 @@ export class BeltModel {
|
|||
* Set or clear a belt slot by `(col, row)` (`0..3`, `0..3`).
|
||||
*/
|
||||
setSlot(col: number, row: number, item: BeltPotion | unknown | null): boolean {
|
||||
if (col < 0 || col >= BELT_COLS || row < 0 || row >= BELT_ROWS) return false
|
||||
if (col < 0 || col >= BELT_COLS || row < 0 || row >= this.maxRows) return false
|
||||
if (item === null || item === undefined) {
|
||||
this.grid[row]![col] = null
|
||||
return true
|
||||
}
|
||||
const potion = itemToBeltPotion(item) ?? ({
|
||||
id: String((item as any)?.id ?? `pot-${col}-${row}`),
|
||||
code: (item as any)?.code ?? 'rvl',
|
||||
name: (item as any)?.name ?? 'Potion',
|
||||
nameZh: (item as any)?.nameZh ?? (item as any)?.name ?? '药剂',
|
||||
invFile: (item as any)?.invFile ?? 'invpotl',
|
||||
kind: 'rejuv',
|
||||
color: '#a855f7',
|
||||
healHp: (item as any)?.healHp ?? 0,
|
||||
healMana: (item as any)?.healMana ?? 0,
|
||||
} as BeltPotion)
|
||||
const potion = itemToBeltPotion(item)
|
||||
if (!potion) return false
|
||||
this.grid[row]![col] = potion
|
||||
return true
|
||||
}
|
||||
|
|
@ -277,11 +328,11 @@ export class BeltModel {
|
|||
const potion = itemToBeltPotion(potionOrItem)
|
||||
if (!potion) return null
|
||||
|
||||
// Pass 1: Match existing potion family in row 0, fill lowest empty upper row (1..BELT_ROWS-1)
|
||||
// Pass 1: Match existing potion family in row 0, fill lowest empty upper row (1..maxRows-1)
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
const basePot = this.grid[0]![col]
|
||||
if (basePot !== null && areBeltPotionsCompatible(basePot, potion)) {
|
||||
for (let row = 1; row < BELT_ROWS; row++) {
|
||||
for (let row = 1; row < this.maxRows; row++) {
|
||||
if (this.grid[row]![col] === null) {
|
||||
this.grid[row]![col] = potion
|
||||
return { row, col, potion }
|
||||
|
|
@ -326,16 +377,15 @@ export class BeltModel {
|
|||
}
|
||||
|
||||
slotAtPoint(logicalX: number, logicalY: number): { row: number; col: number } | null {
|
||||
const visibleRows = this.isExpanded ? BELT_ROWS : 1
|
||||
const visibleRows = this.isExpanded ? this.maxRows : 1
|
||||
for (let row = 0; row < visibleRows; row++) {
|
||||
const sy = BELT_BASE_Y - row * BELT_PITCH
|
||||
for (let col = 0; col < BELT_COLS; col++) {
|
||||
const sx = BELT_BASE_X + col * BELT_PITCH
|
||||
const box = getBeltSlotBox(col, row)
|
||||
if (
|
||||
logicalX >= sx &&
|
||||
logicalX <= sx + BELT_CELL_SIZE &&
|
||||
logicalY >= sy &&
|
||||
logicalY <= sy + BELT_CELL_SIZE
|
||||
logicalX >= box.x &&
|
||||
logicalX <= box.x + box.width &&
|
||||
logicalY >= box.y &&
|
||||
logicalY <= box.y + box.height
|
||||
) {
|
||||
return { row, col }
|
||||
}
|
||||
|
|
@ -345,13 +395,15 @@ export class BeltModel {
|
|||
}
|
||||
|
||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||
const topY = this.isExpanded ? BELT_BASE_Y - (BELT_ROWS - 1) * BELT_PITCH - 4 : BELT_BASE_Y - 2
|
||||
const visibleRows = this.maxRows
|
||||
const topY = this.isExpanded
|
||||
? BELT_BASE_Y - (visibleRows - 1) * BELT_PITCH_Y - 4
|
||||
: BELT_BASE_Y - 2
|
||||
this.hovered =
|
||||
logicalX >= BELT_BASE_X - 4 &&
|
||||
logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH + 4 &&
|
||||
logicalX <= BELT_BASE_X + BELT_COLS * BELT_PITCH_X + 4 &&
|
||||
logicalY >= topY &&
|
||||
logicalY <= BELT_BASE_Y + BELT_CELL_SIZE + 4
|
||||
this.hoveredSlot = this.slotAtPoint(logicalX, logicalY)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,305 @@
|
|||
/**
|
||||
* Parity Test Suite for Core HUD, Mini-Panel, Belts, Death Screen & Experience Table.
|
||||
* Covers Issues #636, #637, #638, #639, #640, #641.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ControlBarHud,
|
||||
MULTIPLAYER_MINIPANEL_ITEMS,
|
||||
SINGLEPLAYER_MINIPANEL_ITEMS,
|
||||
getMiniPanelTooltip,
|
||||
MINIPANEL_BOUNDS,
|
||||
MINIPANEL_SINGLE_BOUNDS,
|
||||
} from '../../src/client/ui/control-bar.ts'
|
||||
import {
|
||||
BeltModel,
|
||||
BELT_POTION_CATALOG,
|
||||
BELT_CODE_TO_ROWS,
|
||||
getBeltRowsForCode,
|
||||
getBeltSlotBox,
|
||||
itemToBeltPotion,
|
||||
areBeltPotionsCompatible,
|
||||
BELT_BASE_X,
|
||||
BELT_BASE_Y,
|
||||
BELT_PITCH_X,
|
||||
BELT_PITCH_Y,
|
||||
BELT_CELL_SIZE,
|
||||
} from '../../src/common/items/belt-model.ts'
|
||||
import {
|
||||
D2_EXPERIENCE_TABLE_99,
|
||||
getExperienceForLevel,
|
||||
getNextLevelExperience,
|
||||
EXPERIENCE_TABLE_BY_CLASS,
|
||||
parseExperienceTable,
|
||||
EXPERIENCE_TXT_RAW,
|
||||
} from '../../src/common/skills/formulas.ts'
|
||||
|
||||
describe('Core HUD Parity Suite (Issues #636–#641)', () => {
|
||||
describe('Issue #636: Mini-Panel 8-Button Layout & Authentic Parity', () => {
|
||||
it('defines 8 authentic buttons for multiplayer and 7 for single-player', () => {
|
||||
expect(MULTIPLAYER_MINIPANEL_ITEMS.length).toBe(8)
|
||||
expect(SINGLEPLAYER_MINIPANEL_ITEMS.length).toBe(7)
|
||||
|
||||
const mpActions = MULTIPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
|
||||
expect(mpActions).toEqual(['char', 'inv', 'skill', 'party', 'automap', 'msg', 'quest', 'menu'])
|
||||
|
||||
const spActions = SINGLEPLAYER_MINIPANEL_ITEMS.map((m) => m.action)
|
||||
expect(spActions).toEqual(['char', 'inv', 'skill', 'automap', 'msg', 'quest', 'menu'])
|
||||
|
||||
// Waypoint / stash must be eliminated
|
||||
expect(mpActions).not.toContain('waypoint')
|
||||
expect(spActions).not.toContain('waypoint')
|
||||
})
|
||||
|
||||
it('indexes icon frames as iconGroup * 2 + (isPressed ? 1 : 0)', () => {
|
||||
for (const item of MULTIPLAYER_MINIPANEL_ITEMS) {
|
||||
expect(typeof item.iconGroup).toBe('number')
|
||||
const normalFrame = item.iconGroup * 2
|
||||
const pressedFrame = item.iconGroup * 2 + 1
|
||||
expect(pressedFrame).toBe(normalFrame + 1)
|
||||
}
|
||||
// Party iconGroup is 3
|
||||
const party = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'party')!
|
||||
expect(party.iconGroup).toBe(3)
|
||||
// Game Menu iconGroup is 7
|
||||
const menu = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')!
|
||||
expect(menu.iconGroup).toBe(7)
|
||||
})
|
||||
|
||||
it('formats tooltips with canonical .tbl keys and minipanelspecial hotkey format', () => {
|
||||
const charItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'char')!
|
||||
expect(getMiniPanelTooltip(charItem, 'ENG')).toBe('Character (A)')
|
||||
expect(getMiniPanelTooltip(charItem, 'CHI')).toBe('人物(A)')
|
||||
|
||||
const invItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'inv')!
|
||||
expect(getMiniPanelTooltip(invItem, 'ENG')).toBe('Inventory (I)')
|
||||
expect(getMiniPanelTooltip(invItem, 'CHI')).toBe('物品(I)')
|
||||
|
||||
// Game Menu already has (Esc) in its tbl string, does not double-append
|
||||
const menuItem = MULTIPLAYER_MINIPANEL_ITEMS.find((m) => m.action === 'menu')!
|
||||
expect(getMiniPanelTooltip(menuItem, 'ENG')).toBe('Game Menu (Esc)')
|
||||
expect(getMiniPanelTooltip(menuItem, 'CHI')).toBe('遊戲選單(Esc)')
|
||||
})
|
||||
|
||||
it('handles mouse clicks and hover correctly across 20px button pitch', () => {
|
||||
const hud = new ControlBarHud()
|
||||
hud.miniPanelOpen = true
|
||||
hud.isMultiplayer = true
|
||||
|
||||
const onMiniPanelAction = vi.fn()
|
||||
const state = {
|
||||
level: 1,
|
||||
xp: 0,
|
||||
currentLevelXp: 0,
|
||||
nextLevelXp: 500,
|
||||
stamina: 100,
|
||||
maxStamina: 100,
|
||||
isRunning: false,
|
||||
unspentStatPoints: 0,
|
||||
unspentSkillPoints: 0,
|
||||
}
|
||||
|
||||
// Button 0 (char): x = 320..339, y = 525..544
|
||||
hud.handleMouseMove(325, 530)
|
||||
expect(hud.hoveredElement).toBe('char')
|
||||
hud.handleClick(325, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
|
||||
expect(onMiniPanelAction).toHaveBeenCalledWith('char')
|
||||
|
||||
// Button 3 (party): x = 380..399
|
||||
hud.handleMouseMove(385, 530)
|
||||
expect(hud.hoveredElement).toBe('party')
|
||||
hud.handleClick(385, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
|
||||
expect(onMiniPanelAction).toHaveBeenCalledWith('party')
|
||||
|
||||
// Button 7 (menu): x = 460..479
|
||||
hud.handleMouseMove(465, 530)
|
||||
expect(hud.hoveredElement).toBe('menu')
|
||||
hud.handleClick(465, 530, state, { onToggleRun: vi.fn(), onMiniPanelAction })
|
||||
expect(onMiniPanelAction).toHaveBeenCalledWith('menu')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #637: Authentic Death Presentation Parity', () => {
|
||||
it('calculates exact 1.13c centered screen offsets (W/2, H/2 - 94) and (W/2, H/2 - 46)', () => {
|
||||
const W = 800
|
||||
const H = 600
|
||||
const centerX = Math.round(W / 2)
|
||||
const centerY = Math.round(H / 2)
|
||||
|
||||
const yHardcore = centerY - 94 // 300 - 94 = 206
|
||||
const yInst = centerY - 46 // 300 - 46 = 254
|
||||
|
||||
expect(centerX).toBe(400)
|
||||
expect(yHardcore).toBe(206)
|
||||
expect(yInst).toBe(254)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #638: Belt Slot Grid Indexing & Left-Click Interaction', () => {
|
||||
it('accesses belt slot using getSlot(col, row) without inverted indexing', () => {
|
||||
const belt = new BeltModel()
|
||||
const potion = itemToBeltPotion({ code: 'hp1', id: 'pot-hp1' })!
|
||||
// Put potion in col 2, row 1
|
||||
belt.grid[1]![2] = potion
|
||||
|
||||
expect(belt.getSlot(2, 1)).toBe(potion)
|
||||
expect(belt.getSlot(1, 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #639: Experience Table & Experience.txt 1.13c Parity', () => {
|
||||
it('parses raw Experience.txt correctly into 100 levels (indices 0..99)', () => {
|
||||
const parsed = parseExperienceTable(EXPERIENCE_TXT_RAW)
|
||||
expect(parsed.maxLevel).toBe(99)
|
||||
expect(Object.keys(parsed.tableByClass).length).toBe(7)
|
||||
|
||||
// All classes identical progression
|
||||
for (const cls of ['amazon', 'sorceress', 'necromancer', 'paladin', 'barbarian', 'druid', 'assassin']) {
|
||||
const table = parsed.tableByClass[cls]!
|
||||
expect(table.length).toBe(100)
|
||||
expect(table[1]).toBe(0)
|
||||
expect(table[2]).toBe(500)
|
||||
expect(table[17]).toBe(275207)
|
||||
expect(table[30]).toBe(4663553)
|
||||
expect(table[90]).toBe(1618470619)
|
||||
expect(table[99]).toBe(3520485254)
|
||||
}
|
||||
})
|
||||
|
||||
it('getExperienceForLevel and getNextLevelExperience conform to 1.13c', () => {
|
||||
expect(getExperienceForLevel(1)).toBe(0)
|
||||
expect(getExperienceForLevel(2)).toBe(500)
|
||||
expect(getExperienceForLevel(17)).toBe(275207)
|
||||
expect(getExperienceForLevel(30)).toBe(4663553)
|
||||
expect(getExperienceForLevel(90)).toBe(1618470619)
|
||||
expect(getExperienceForLevel(99)).toBe(3520485254)
|
||||
|
||||
expect(getNextLevelExperience(1)).toBe(500)
|
||||
expect(getNextLevelExperience(16)).toBe(275207)
|
||||
expect(getNextLevelExperience(29)).toBe(4663553)
|
||||
expect(getNextLevelExperience(89)).toBe(1618470619)
|
||||
expect(getNextLevelExperience(98)).toBe(3520485254)
|
||||
expect(getNextLevelExperience(99)).toBe(3520485254)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #640: Belts.txt Dynamic Rows & Coordinate Parity', () => {
|
||||
it('conforms to 1.13c belts.txt coordinates (base x=423, base y=562, pitch x=31, pitch y=32, box=29x29)', () => {
|
||||
expect(BELT_BASE_X).toBe(423)
|
||||
expect(BELT_BASE_Y).toBe(562)
|
||||
expect(BELT_PITCH_X).toBe(31)
|
||||
expect(BELT_PITCH_Y).toBe(32)
|
||||
expect(BELT_CELL_SIZE).toBe(29)
|
||||
|
||||
// Row 0 box
|
||||
const box0 = getBeltSlotBox(0, 0)
|
||||
expect(box0).toEqual({ x: 423, y: 562, width: 29, height: 29 })
|
||||
|
||||
// Row 1 box
|
||||
const box1 = getBeltSlotBox(0, 1)
|
||||
expect(box1).toEqual({ x: 423, y: 530, width: 29, height: 29 })
|
||||
|
||||
// Row 3 box (top row of 4x4 belt)
|
||||
const box3 = getBeltSlotBox(3, 3)
|
||||
expect(box3).toEqual({ x: 423 + 3 * 31, y: 562 - 3 * 32, width: 29, height: 29 })
|
||||
})
|
||||
|
||||
it('dynamically adapts maxRows according to equipped belt code', () => {
|
||||
const belt = new BeltModel()
|
||||
|
||||
// Default (no equipped belt): 1 row (4 boxes)
|
||||
belt.setEquippedBelt(null)
|
||||
expect(belt.maxRows).toBe(1)
|
||||
expect(getBeltRowsForCode(null)).toBe(1)
|
||||
|
||||
// Sash (lbl): 2 rows (8 boxes)
|
||||
belt.setEquippedBelt('lbl')
|
||||
expect(belt.maxRows).toBe(2)
|
||||
|
||||
// Light Belt (vbl): 2 rows (8 boxes)
|
||||
belt.setEquippedBelt('vbl')
|
||||
expect(belt.maxRows).toBe(2)
|
||||
|
||||
// Belt (mbl): 3 rows (12 boxes)
|
||||
belt.setEquippedBelt('mbl')
|
||||
expect(belt.maxRows).toBe(3)
|
||||
|
||||
// Heavy Belt (tbl): 3 rows (12 boxes)
|
||||
belt.setEquippedBelt('tbl')
|
||||
expect(belt.maxRows).toBe(3)
|
||||
|
||||
// Girdle (hbl): 4 rows (16 boxes)
|
||||
belt.setEquippedBelt('hbl')
|
||||
expect(belt.maxRows).toBe(4)
|
||||
|
||||
// Exceptional & Elite belts: 4 rows
|
||||
for (const code of ['zlb', 'zvb', 'zmb', 'ztb', 'zhb', 'ulc', 'uvc', 'umc', 'utc', 'uhc']) {
|
||||
belt.setEquippedBelt(code)
|
||||
expect(belt.maxRows).toBe(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('slotAtPoint hit tests only visible rows according to maxRows', () => {
|
||||
const belt = new BeltModel()
|
||||
belt.setEquippedBelt('lbl') // 2 rows
|
||||
belt.lockedOpen = true // expanded to maxRows (2)
|
||||
|
||||
// Row 0 col 0
|
||||
expect(belt.slotAtPoint(425, 565)).toEqual({ row: 0, col: 0 })
|
||||
// Row 1 col 0
|
||||
expect(belt.slotAtPoint(425, 535)).toEqual({ row: 1, col: 0 })
|
||||
// Row 2 col 0 (outside 2-row belt)
|
||||
expect(belt.slotAtPoint(425, 500)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #641: Belt Potion Assets & Scroll Eligibility', () => {
|
||||
it('maps authentic invFile names per 1.13c misc.txt', () => {
|
||||
expect(BELT_POTION_CATALOG['vps']?.invFile).toBe('invwps') // Stamina
|
||||
expect(BELT_POTION_CATALOG['yps']?.invFile).toBe('invnps') // Antidote
|
||||
expect(BELT_POTION_CATALOG['wms']?.invFile).toBe('invyps') // Thawing
|
||||
|
||||
expect(BELT_POTION_CATALOG['rvs']?.invFile).toBe('invvps') // Rejuv
|
||||
expect(BELT_POTION_CATALOG['rvl']?.invFile).toBe('invvpl') // Full Rejuv
|
||||
|
||||
expect(BELT_POTION_CATALOG['tsc']?.invFile).toBe('invbsc') // Town Portal Scroll
|
||||
expect(BELT_POTION_CATALOG['isc']?.invFile).toBe('invrsc') // Identify Scroll
|
||||
})
|
||||
|
||||
it('allows scrolls (tsc/isc) to be placed into belt slots', () => {
|
||||
const tsc = itemToBeltPotion({ code: 'tsc', id: 'scroll-tp' })
|
||||
expect(tsc).not.toBeNull()
|
||||
expect(tsc?.kind).toBe('scroll')
|
||||
expect(tsc?.invFile).toBe('invbsc')
|
||||
|
||||
const isc = itemToBeltPotion({ code: 'isc', id: 'scroll-id' })
|
||||
expect(isc).not.toBeNull()
|
||||
expect(isc?.kind).toBe('scroll')
|
||||
expect(isc?.invFile).toBe('invrsc')
|
||||
|
||||
const belt = new BeltModel()
|
||||
expect(belt.setSlot(0, 0, tsc)).toBe(true)
|
||||
expect(belt.getSlot(0, 0)?.code).toBe('tsc')
|
||||
})
|
||||
|
||||
it('rejects weapon throwing potions and invalid non-beltable items', () => {
|
||||
for (const throwPot of ['gpm', 'opm', 'gpl', 'opl', 'gps', 'ops']) {
|
||||
expect(itemToBeltPotion({ code: throwPot })).toBeNull()
|
||||
}
|
||||
expect(itemToBeltPotion({ code: 'axe' })).toBeNull()
|
||||
expect(itemToBeltPotion({ code: 'key' })).toBeNull()
|
||||
})
|
||||
|
||||
it('requires scrolls to have identical code for column compatibility', () => {
|
||||
const tsc1 = itemToBeltPotion({ code: 'tsc' })!
|
||||
const tsc2 = itemToBeltPotion({ code: 'tsc' })!
|
||||
const isc = itemToBeltPotion({ code: 'isc' })!
|
||||
const hp = itemToBeltPotion({ code: 'hp1' })!
|
||||
|
||||
expect(areBeltPotionsCompatible(tsc1, tsc2)).toBe(true)
|
||||
expect(areBeltPotionsCompatible(tsc1, isc)).toBe(false)
|
||||
expect(areBeltPotionsCompatible(tsc1, hp)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -512,6 +512,9 @@ describe('client hardcode audit — server-authoritative HUD', () => {
|
|||
const { world, hud, model, commands } = setup()
|
||||
assignSelf(world, 1)
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
} as any
|
||||
|
|
@ -520,11 +523,14 @@ describe('client hardcode audit — server-authoritative HUD', () => {
|
|||
model.syncFromWorld()
|
||||
expect(hud.isDead).toBe(true)
|
||||
|
||||
const fontSpy = vi.spyOn(hud.font, 'drawText')
|
||||
hud.render()
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('You Have Died'),
|
||||
expect(fontSpy).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
'YOU HAVE DIED',
|
||||
400,
|
||||
300,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({ color: 'red' }),
|
||||
)
|
||||
|
||||
hud.handleClick(400, 300)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import {
|
|||
checkHitRecovery,
|
||||
calculateAttack,
|
||||
D2_EXPERIENCE_TABLE_99,
|
||||
getExperienceForLevel,
|
||||
getNextLevelExperience,
|
||||
getExperienceTable,
|
||||
parseExperienceTable,
|
||||
EXPERIENCE_TABLE_BY_CLASS,
|
||||
} from '../src/common/skills/formulas.ts'
|
||||
|
||||
describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => {
|
||||
|
|
@ -435,18 +440,19 @@ describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('D2_EXPERIENCE_TABLE_99', () => {
|
||||
describe('D2_EXPERIENCE_TABLE_99 and Experience.txt Parity (Issue #639)', () => {
|
||||
it('contains exact 100 elements (indices 0 to 99)', () => {
|
||||
expect(D2_EXPERIENCE_TABLE_99.length).toBe(100)
|
||||
})
|
||||
|
||||
it('matches known progression thresholds', () => {
|
||||
it('matches authentic 1.13c progression thresholds across all classes', () => {
|
||||
expect(D2_EXPERIENCE_TABLE_99[1]).toBe(0)
|
||||
expect(D2_EXPERIENCE_TABLE_99[2]).toBe(500)
|
||||
expect(D2_EXPERIENCE_TABLE_99[10]).toBe(57715)
|
||||
expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663904)
|
||||
expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1619361124)
|
||||
expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3522678586)
|
||||
expect(D2_EXPERIENCE_TABLE_99[17]).toBe(275207)
|
||||
expect(D2_EXPERIENCE_TABLE_99[30]).toBe(4663553)
|
||||
expect(D2_EXPERIENCE_TABLE_99[90]).toBe(1618470619)
|
||||
expect(D2_EXPERIENCE_TABLE_99[99]).toBe(3520485254)
|
||||
})
|
||||
|
||||
it('is strictly monotonic increasing from level 1 to 99', () => {
|
||||
|
|
@ -454,6 +460,30 @@ describe('Diablo II Combat Mathematics (Issue #24 / M9)', () => {
|
|||
expect(D2_EXPERIENCE_TABLE_99[i]!).toBeGreaterThan(D2_EXPERIENCE_TABLE_99[i - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('provides getExperienceForLevel and getNextLevelExperience helper parity', () => {
|
||||
expect(getExperienceForLevel(1)).toBe(0)
|
||||
expect(getExperienceForLevel(2)).toBe(500)
|
||||
expect(getExperienceForLevel(17)).toBe(275207)
|
||||
expect(getExperienceForLevel(30)).toBe(4663553)
|
||||
expect(getExperienceForLevel(90)).toBe(1618470619)
|
||||
expect(getExperienceForLevel(99)).toBe(3520485254)
|
||||
|
||||
expect(getNextLevelExperience(1)).toBe(500)
|
||||
expect(getNextLevelExperience(16)).toBe(275207)
|
||||
expect(getNextLevelExperience(29)).toBe(4663553)
|
||||
expect(getNextLevelExperience(89)).toBe(1618470619)
|
||||
expect(getNextLevelExperience(98)).toBe(3520485254)
|
||||
expect(getNextLevelExperience(99)).toBe(3520485254)
|
||||
|
||||
// Test across specific classes
|
||||
const classes = ['amazon', 'sorceress', 'necromancer', 'paladin', 'barbarian', 'druid', 'assassin']
|
||||
for (const cls of classes) {
|
||||
expect(getExperienceForLevel(30, cls)).toBe(4663553)
|
||||
expect(getExperienceForLevel(99, cls)).toBe(3520485254)
|
||||
expect(EXPERIENCE_TABLE_BY_CLASS[cls]?.[99]).toBe(3520485254)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PvE Depth Mechanics (Issue #39)', () => {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ describe('Milestone 4 — Visual Integrity, Ground Labels, Auto-Belt & Multiplay
|
|||
belt.clear()
|
||||
|
||||
// Verify catalog & compatibility rules
|
||||
expect(Object.keys(BELT_POTION_CATALOG).length).toBe(15)
|
||||
expect(Object.keys(BELT_POTION_CATALOG).length).toBe(21)
|
||||
expect(isAutoBeltablePotion({ code: 'hp3' })).toBe(true)
|
||||
expect(isAutoBeltablePotion({ code: 'rvs' })).toBe(true)
|
||||
expect(isAutoBeltablePotion({ code: 'vps' })).toBe(true)
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
|||
expect(belt.grid[1]![0]?.code).toBe('vps')
|
||||
// Antidote does not match Stamina in col 0, so Pass 2 places it in empty col 1 row 0
|
||||
expect(belt.autoPlacePotionSlot({ code: 'yps', name: 'Antidote Potion' })).toMatchObject({ col: 1, row: 0 })
|
||||
// Non-beltable items (scrolls, equipment, throwing potions) return false
|
||||
expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(false)
|
||||
// Beltable scrolls ('tsc', 'isc' per 1.13c Misc.txt belt=1) return true; non-beltable throwing potions return false
|
||||
expect(belt.autoPlacePotion({ code: 'tsc', name: 'Scroll of Town Portal' })).toBe(true)
|
||||
expect(belt.autoPlacePotion({ code: 'gpm', name: 'Rancid Gas Potion' })).toBe(false)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue