From bc6b5cd331179b16f9fe85eaeff4acdf4631bdb2 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Tue, 22 Sep 2026 06:19:19 +0000 Subject: [PATCH] feat(skills): add normal attack icon and enable dual left/right slot binding (Fixes #151) --- public/skills/icon_0.png | Bin 0 -> 2809 bytes public/skills/icon_0_active.png | Bin 0 -> 2702 bytes public/ui/icon_0.png | Bin 0 -> 2809 bytes scripts/pack-ui.ts | 17 ++ src/scene/act-scene.ts | 374 ++++++++++++++++++++++++++++-- src/ui/hotkeys.ts | 136 ++++++++++- tests/attack-skill.test.ts | 392 ++++++++++++++++++++++++++++++++ tests/hotkeys.test.ts | 147 ++++++++++++ 8 files changed, 1039 insertions(+), 27 deletions(-) create mode 100644 public/skills/icon_0.png create mode 100644 public/skills/icon_0_active.png create mode 100644 public/ui/icon_0.png create mode 100644 tests/attack-skill.test.ts create mode 100644 tests/hotkeys.test.ts diff --git a/public/skills/icon_0.png b/public/skills/icon_0.png new file mode 100644 index 0000000000000000000000000000000000000000..ca65c842d65db01a51de18854bd091f3a38ceddb GIT binary patch literal 2809 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 IM6N<$f~-@wb^rhX literal 0 HcmV?d00001 diff --git a/public/ui/icon_0.png b/public/ui/icon_0.png new file mode 100644 index 0000000000000000000000000000000000000000..ca65c842d65db01a51de18854bd091f3a38ceddb GIT binary patch literal 2809 zcmVwkN^OTND#aL0MLXW^u%n8d{C^MWDE=l6c7*`92_Vl z94ss>JUB2oI5&fI6kH?-R5%z^I3#Qs5NIqATtp;rBp7HsBz!CkWK1k@NCL^QNaAeeAi zpm;#MU>ux;NW@fB$ZR02d|0rENXUFt;BX+goK*ZQ0KklJ%$QK*gfQT6aL|}|_=r60 zpg7pLWZiroE&h3I7py0SddIayeNd2NMN{382kWq++2*%e4zY@3=Ak37)SsBSU40! z5F}_QJY*;+cuWw0I81~vFqBAOj5u7NP*AK)Y^+pxyd+@QWO&eIkl=uz+@v7kc4!sh;WdQkerl+fRwnPprEj*v!oQH zJL{dD*-oxrd1q#|^XK>de&#o;;mfud0{jyA?CskFf7GD8bL&@F1EBkWMoMW_4d1yJ zQN=W-0kf{l@eMVQHKj z%)=SEyf>Ihf3aqNO+I`3mc+~?CufeMP)Sonw9ac|i)|>y)7~hG$(5W9srv$K;WEGy zsAGOY!sa$?6b(WuGPX%C)lMJiUu?(0f)7f3H+I5vCb#dPv#q-Brk2xLGj z4952zrl#fo1Mc?Ah7sBSObS<|j(SYrjg>NN9CJamZO3aYc8ak^#Q>?5Y2aO&4&F@b z2M%^kW}OnOP>Kq6&8Yy5`F5YTMLQe8@T+)Ch64jc1!yB8MuL}F&ay@kN=B_u2c=pn z8&Gq+slD8M7UTNjP* zT&Fm-rgfq|(^#j=$*!wQ&%KUaiO0)GlA{o=9%LcF5%1onOTN;o7B+V*nT=;NSqW|{94}-jRXUoHKo#I^Zs_NWSr2}1 z?|&K{qbxi_LZPWqg~hpLX|AEGe!3}ws=X&@D-oWZJ6kfL^M+Z;BE)SK12k|!a~X&* zGa^b9Y#U@4y}XIDH+w{79*5`oB@MRHQHgpm8P zxz#W`nAjVg`jjRjPT6(!m4rl*JzCF<24flH%0EH{pLSxe-ty zsULS?s{L@?RgsDzJC22q7#I>M_!OaBC1*I(3wa@k!#&CZxZx?L-4W>}+kdh=b&=-6UUF=KmOC>U!LgiKk-%f{RiC-x({{tt0xco z`~Upbn{WL5@2CGpuJ-`-C~2Z^e99NP^4UFY!7Qp(h}!V6BgYD0G~+ESwWGIP=0Zg) zs!@GZ(WL-_UKlnpO{lJg8Q%#*mf5p`dg6BTqvqZRG&hzIHA%47n+C&u6>#r43pb_V}{TReXG%AUp=w1b5kjkGb)VR z!9#~ljQ>AkB3dL9Qo49;IeRgyW;o>8rE5)lnfl%=`GivHutsL}Js~FA;_(hs6D{8Jyko0_t3}XgJF+3;ITi`MvbmZ|0jf^xb8iPzSX#Q?N@foc6>c8X306I|@0-&`YFs;8`JgNG^@tZC9T?+>lp-9!v|Cs&{I7!g39=$Ib00000 LNkvXXu0mjf=@8 { const genericSkills = await decodeUiDc6('data/global/ui/SPELLS/Skillicon.DC6') const gsStrip = stitchHorizontalStrip(genericSkills.groups[0]!.frames) savePng('generic-skills.png', gsStrip.width, gsStrip.height, gsStrip.pixels) + const attackFrame = genericSkills.groups[0]!.frames[0]! + savePng('icon_0.png', attackFrame.width, attackFrame.height, attackFrame.indices) + try { + const skillsOut = join(process.cwd(), 'public', 'skills') + mkdirSync(skillsOut, { recursive: true }) + const attackPng = encodeIndexedPng({ + width: attackFrame.width, + height: attackFrame.height, + pixels: attackFrame.indices, + palette: pl2.rgb, + transparentIndex: 0, + }) + writeFileSync(join(skillsOut, 'icon_0.png'), attackPng) + } catch { + // optional copy to public/skills + } const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6') savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices) @@ -662,6 +678,7 @@ async function main(): Promise { levelSocket: '/ui/level-socket.png', skillPoints: '/ui/skillpoints.png', genericSkills: '/ui/generic-skills.png', + attackIcon: '/ui/icon_0.png', cursorHand: '/ui/cursor-hand.png', cursorAtlas: '/ui/cursor.png', questTabs: '/ui/quest-tabs.png', diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index b59b99d..8baab26 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -66,6 +66,7 @@ import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } f import { Minimap } from '../ui/minimap.ts' import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts' import { HudManager } from '../ui/hud-manager.ts' +import type { InventoryPanel } from '../ui/inventory.ts' /** Base locations tried in order when `?base=` is absent. */ const DEFAULT_BASES = ['samples/d2', '/diablo2/data'] @@ -687,8 +688,214 @@ export function isPassiveSkill(skillId: number): boolean { return def?.type === 'passive' } -export function getSkillRange(skillId: number): number { - if (skillId === 0) return 52 // playerReach melee +export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged' +export type AttackWeaponSubtype = + | 'fist' + | 'sword' + | 'axe' + | 'mace' + | 'dagger' + | 'staff' + | 'polearm' + | 'spear' + | 'bow' + | 'crossbow' + | 'other' + +export interface AttackWeaponInfo { + readonly kind: AttackWeaponKind + readonly subtype: AttackWeaponSubtype + readonly isRanged: boolean + readonly ammoType?: 'arrow' | 'bolt' | undefined + readonly damageMin: number + readonly damageMax: number + readonly reach: number + readonly cooldown: number + readonly swingTicks: number +} + +const BOW_CODES = new Set([ + 'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb', + 'am1', 'am2', 'am6', 'am7', 'amb', + '8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw', + '6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw', + 'amc', 'amd', 'ame', 'amj', 'amk', +]) + +const XBOW_CODES = new Set([ + 'lxb', 'mxb', 'hxb', 'rxb', + '8lx', '8mx', '8hx', '8rx', + '6lx', '6mx', '6hx', '6rx', +]) + +export interface AttackWeaponInputItem { + code?: string | undefined + type?: string | undefined + wclass?: string | undefined + name?: string | undefined + nameZh?: string | undefined + damage?: string | undefined +} + +export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo { + if (!item || !item.code) { + return { + kind: 'unarmed', + subtype: 'fist', + isRanged: false, + damageMin: 1, + damageMax: 2, + reach: 52, + cooldown: 12, + swingTicks: 6, + } + } + + const code = item.code.trim().toLowerCase() + const type = (item.type ?? '').trim().toLowerCase() + const wclass = (item.wclass ?? '').trim().toLowerCase() + const name = (item.name ?? '').toLowerCase() + const nameZh = (item.nameZh ?? '').toLowerCase() + + let dMin = 3 + let dMax = 8 + if (item.damage) { + const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/) + if (m) { + dMin = parseInt(m[1], 10) + dMax = parseInt(m[2], 10) + } + } + + // 1. Crossbow (checked before bow because 'crossbow' contains 'bow') + if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) { + return { + kind: 'ranged', + subtype: 'crossbow', + isRanged: true, + ammoType: 'bolt', + damageMin: dMin, + damageMax: dMax, + reach: 450, + cooldown: 16, + swingTicks: 8, + } + } + + // 2. Bow + if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) { + return { + kind: 'ranged', + subtype: 'bow', + isRanged: true, + ammoType: 'arrow', + damageMin: dMin, + damageMax: dMax, + reach: 450, + cooldown: 14, + swingTicks: 7, + } + } + + // 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear + let subtype: AttackWeaponSubtype | null = null + let reach = 52 + + if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) { + subtype = 'sword' + reach = 52 + } else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) { + subtype = 'axe' + reach = 52 + } else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) { + subtype = 'mace' + reach = 52 + } else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) { + subtype = 'dagger' + reach = 48 + } else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) { + subtype = 'staff' + reach = 52 + } else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) { + subtype = 'polearm' + reach = 60 + } else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) { + subtype = 'spear' + reach = 60 + } + + if (subtype !== null) { + return { + kind: 'melee', + subtype, + isRanged: false, + damageMin: dMin, + damageMax: dMax, + reach, + cooldown: 14, + swingTicks: 7, + } + } + + // Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6 + return { + kind: 'unarmed', + subtype: 'fist', + isRanged: false, + damageMin: dMin > 3 ? dMin : 1, + damageMax: dMax > 8 ? dMax : 2, + reach: 52, + cooldown: 12, + swingTicks: 6, + } +} + +export function checkAmmunition( + ammoType: 'arrow' | 'bolt', + offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined, + inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | InventoryPanel | null | undefined, +): boolean { + // 1. Check offhand equipment (weapon2) + if (offhandItem && offhandItem.code) { + const code = offhandItem.code.toLowerCase().trim() + const name = (offhandItem.name ?? '').toLowerCase() + const nameZh = (offhandItem.nameZh ?? '').toLowerCase() + if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true + if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true + } + + // 2. Check inventory grid / items + if (inventory) { + const invAny = inventory as any + const list: any[] = invAny.gridItems + ? invAny.gridItems.map((g: any) => g.item ?? g) + : invAny.grid + ? invAny.grid.map((g: any) => g.item ?? g) + : (invAny.items ?? []) + for (const it of list) { + if (!it || !it.code) continue + const code = String(it.code).toLowerCase().trim() + const name = String(it.name ?? '').toLowerCase() + const nameZh = String(it.nameZh ?? '').toLowerCase() + if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true + if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true + } + } + + return false +} + +export function getSkillRange( + skillId: number, + hudManager?: HudManager | null | undefined, + equippedWeapon?: AttackWeaponInputItem | null | undefined, +): number { + if (skillId === 0) { + const hud = hudManager ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance : null) + const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1 + const info = getAttackWeaponInfo(weapon) + return info.reach + } const def = getBatch1SkillDef(skillId) if (def !== undefined) { if (def.type === 'melee') return 52 @@ -739,6 +946,9 @@ export function castSkill( status: HTMLElement playerAnimator?: ActorAnimator | undefined character?: LoadedCharacter | null | undefined + equippedWeapon?: any | null | undefined + equippedAmmo?: any | null | undefined + hasAmmo?: boolean | undefined }, ): boolean { const { engine, runtime, hudManager, status, playerAnimator, character } = context @@ -783,16 +993,58 @@ export function castSkill( // 5. Dispatch skill if (skillId === 0) { - // Normal Melee Attack - player.cooldown = 12 - player.swingTicks = 6 + const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null) + const weaponInfo = getAttackWeaponInfo(weapon) + + if (weaponInfo.isRanged) { + // Ranged Attack (Bow / Crossbow) + const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null) + const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory) + if (!hasAmmo) { + status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!' + return false + } + + player.cooldown = weaponInfo.cooldown + player.swingTicks = weaponInfo.swingTicks + + const dx = targetX - player.x + const dy = targetY - player.y + const len = Math.hypot(dx, dy) + const speed = 500 + const range = weaponInfo.reach + const perTick = speed / 25 + const ttl = Math.max(1, Math.round(range / perTick)) + + const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2) + + const projectile: Projectile = { + skillId: '0', + x: player.x, + y: player.y, + vx: len > 0 ? (dx / len) * perTick : perTick, + vy: len > 0 ? (dy / len) * perTick : 0, + damage, + ttl, + fromPlayer: true, + } + + engine.projectiles.push(projectile) + engine.metrics.casts += 1 + status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭' + return true + } + + // Unarmed or Melee Attack + player.cooldown = weaponInfo.cooldown + player.swingTicks = weaponInfo.swingTicks + let targetMonster: Monster | null = null - // Prioritize monster near target cursor within melee reach of player let bestTargetDist = 36 for (const monster of engine.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y) - if (distToPlayer <= 52) { + if (distToPlayer <= weaponInfo.reach) { const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY) const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY) const minD = Math.min(distFeet, distBody) @@ -802,9 +1054,8 @@ export function castSkill( } } } - // Fallback: closest monster to player within melee reach if (targetMonster === null) { - let bestDist = 52 + let bestDist = weaponInfo.reach for (const monster of engine.world.monsters) { if (monster.state === 'dead' || monster.hp <= 0) continue const dist = Math.hypot(monster.x - player.x, monster.y - player.y) @@ -814,10 +1065,13 @@ export function castSkill( } } } + + const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2) if (targetMonster !== null) { - damageMonster(engine.world, targetMonster.index, 7) + damageMonster(engine.world, targetMonster.index, damage) engine.metrics.playerHits += 1 - status.textContent = `普通攻击命中:${targetMonster.stats.name || targetMonster.stats.id}` + const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击' + status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}` } else { engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) } @@ -985,7 +1239,7 @@ export type PendingInteraction = | { readonly kind: 'npc'; readonly npc: NpcEntity } | { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } } | { readonly kind: 'stash'; readonly stash: TownStashEntity } - | { readonly kind: 'monster'; readonly monster: Monster } + | { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number } declare global { interface HTMLCanvasElement { @@ -1125,7 +1379,17 @@ export class SceneMouseController { } } else if (e.button === 2) { this.isRightMouseDown = true - this.handleRightClickWorld(worldX, worldY) + if (this.input.shiftHeld) { + this.activeClickType = 'none' + this.navTarget = null + this.pendingInteraction = null + const player = this.engine.world.player + player.facing = facingOf(worldX - player.x, worldY - player.y) + const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 + this.castSkill(rightSkillId, worldX, worldY) + } else { + this.handleRightClickWorld(worldX, worldY) + } } } @@ -1144,7 +1408,7 @@ export class SceneMouseController { this.navTarget = { x: worldX, y: worldY } } else if (this.isRightMouseDown) { const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) { + if (isPassiveSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) { const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY) this.navTarget = { x: worldX, y: worldY } } @@ -1166,14 +1430,63 @@ export class SceneMouseController { handleRightClickWorld(worldX: number, worldY: number): void { const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) { + if (isPassiveSkill(rightSkillId)) { // Walk/Run fallback this.pendingInteraction = null this.navTarget = { x: worldX, y: worldY } this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y) this.stuckTicks = 0 + return + } + + const player = this.engine.world.player + const range = getSkillRange(rightSkillId, this.hudManager) + + // 1. Check Monster: living monster within ~36 px + let clickedMonster: Monster | null = null + let bestMonDist = 36 + for (const m of this.engine.world.monsters) { + if (m.state === 'dead' || m.hp <= 0) continue + const distFeet = Math.hypot(m.x - worldX, m.y - worldY) + const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY) + const minD = Math.min(distFeet, distBody) + if (minD <= bestMonDist) { + bestMonDist = minD + clickedMonster = m + } + } + + if (clickedMonster !== null) { + this.activeClickType = 'monster' + const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) + if (distToPlayer <= range) { + this.navTarget = null + this.pendingInteraction = null + player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y) + this.castSkill(rightSkillId, clickedMonster.x, clickedMonster.y) + } else { + this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId } + this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } + } + return + } + + // 2. Ground clicked with active right skill + if (rightSkillId === 0) { + const weapon = this.hudManager?.inventory?.equipped?.weapon1 + const weaponInfo = getAttackWeaponInfo(weapon) + if (weaponInfo.isRanged) { + this.navTarget = null + this.pendingInteraction = null + this.castSkill(rightSkillId, worldX, worldY) + } else { + this.activeClickType = 'ground' + this.pendingInteraction = null + this.navTarget = { x: worldX, y: worldY } + this.lastDist = Math.hypot(worldX - player.x, worldY - player.y) + this.stuckTicks = 0 + } } else { - // Active skill execution: halt movement and cancel pending interactions this.navTarget = null this.pendingInteraction = null this.castSkill(rightSkillId, worldX, worldY) @@ -1280,7 +1593,7 @@ export class SceneMouseController { if (clickedMonster !== null) { this.activeClickType = 'monster' const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - const range = getSkillRange(leftSkillId) + const range = getSkillRange(leftSkillId, this.hudManager) const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) if (distToPlayer <= range) { this.navTarget = null @@ -1288,7 +1601,7 @@ export class SceneMouseController { player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y) this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y) } else { - this.pendingInteraction = { kind: 'monster', monster: clickedMonster } + this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId } this.navTarget = { x: clickedMonster.x, y: clickedMonster.y } } return @@ -1318,7 +1631,16 @@ export class SceneMouseController { }) } - castSkill(skillId: number, targetX: number, targetY: number): boolean { + castSkill( + skillId: number, + targetX: number, + targetY: number, + options?: { + equippedWeapon?: any + equippedAmmo?: any + hasAmmo?: boolean + } + ): boolean { return castSkill(skillId, targetX, targetY, { engine: this.engine, runtime: this.getRuntime(), @@ -1326,6 +1648,9 @@ export class SceneMouseController { status: this.status, playerAnimator: this.playerAnimator, character: this.getCharacter ? this.getCharacter() : undefined, + equippedWeapon: options?.equippedWeapon, + equippedAmmo: options?.equippedAmmo, + hasAmmo: options?.hasAmmo, }) } @@ -1379,7 +1704,10 @@ export class SceneMouseController { } } else if (this.isRightMouseDown && this.lastClientPos !== null) { const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 - if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) { + if (isPassiveSkill(rightSkillId)) { + const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) + this.navTarget = { x: worldX, y: worldY } + } else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') { const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) this.navTarget = { x: worldX, y: worldY } } else if (player.cooldown === 0) { @@ -1437,14 +1765,14 @@ export class SceneMouseController { this.navTarget = null this.pendingInteraction = null } else { - const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 - const range = getSkillRange(leftSkillId) + const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47) + const range = getSkillRange(skillId, this.hudManager) const dist = Math.hypot(monster.x - player.x, monster.y - player.y) if (dist <= range) { this.navTarget = null this.pendingInteraction = null player.facing = facingOf(monster.x - player.x, monster.y - player.y) - this.castSkill(leftSkillId, monster.x, monster.y) + this.castSkill(skillId, monster.x, monster.y) } else { this.navTarget = { x: monster.x, y: monster.y } } diff --git a/src/ui/hotkeys.ts b/src/ui/hotkeys.ts index df1170e..1778dd9 100644 --- a/src/ui/hotkeys.ts +++ b/src/ui/hotkeys.ts @@ -21,11 +21,57 @@ export interface HotkeySkillEntry { readonly rightUsable: boolean } +export interface SkillIconMapping { + readonly skillId: number + readonly name: string + readonly iconPath: string + readonly isGeneric: boolean + readonly dc6Path: string + readonly dc6Source: string + readonly frameIndex: number + readonly atlasRect: { readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly w?: number; readonly h?: number } +} + +/** + * Resolves authentic icon mapping for skillId (Parity with 1.13c Skillicon.DC6 frame 0). + */ +export function resolveSkillIcon(skillId: number): SkillIconMapping { + if (skillId === 0) { + return { + skillId: 0, + name: 'Attack', + iconPath: '/skills/icon_0.png', + isGeneric: true, + dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6', + dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6', + frameIndex: 0, + atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 }, + } + } + return { + skillId, + name: `Skill ${skillId}`, + iconPath: `/skills/icon_${skillId}.png`, + isGeneric: false, + dc6Path: '', + dc6Source: '', + frameIndex: skillId, + atlasRect: { x: 0, y: 0, width: 48, height: 48, w: 48, h: 48 }, + } +} + +export function getAvailableSkillsForSide( + side: 'left' | 'right', + skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS, +): readonly HotkeySkillEntry[] { + return skills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable)) +} + export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [ - { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: false }, + { skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, { skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true }, @@ -46,6 +92,18 @@ export class SkillHotkeysHud { openPopup: 'left' | 'right' | null = null /** Hovered skill inside the Speedbar popup. */ hoveredPopupSkill: { side: 'left' | 'right'; skillId: number } | null = null + /** Currently hovered skill ID in left or right slot. */ + hoverSkillId: number | null = null + /** Currently hovered skill ID inside speedbar. */ + hoverSpeedbarSkillId: number | null = null + + get selectionOpen(): 'left' | 'right' | null { + return this.openPopup + } + set selectionOpen(val: 'left' | 'right' | null) { + this.openPopup = val + } + /** F1..F8 bindings (`F1`..'F8' -> `{ side, skillId }`). */ readonly bindings = new Map([ ['F1', { side: 'right', skillId: 64 }], @@ -64,7 +122,7 @@ export class SkillHotkeysHud { preloadSkillIcons(): void { if (typeof Image === 'undefined') return for (const s of this.availableSkills) { - if (s.skillId > 0 && !this.iconImages.has(s.skillId)) { + if (s.skillId >= 0 && !this.iconImages.has(s.skillId)) { const img = new Image() img.src = `/skills/icon_${s.skillId}.png` this.iconImages.set(s.skillId, img) @@ -83,6 +141,42 @@ export class SkillHotkeysHud { this.bindings.set(fKey.toUpperCase(), { side, skillId }) } + getHotkey(key: string): number | null { + const bound = this.bindings.get(key.toUpperCase()) + return bound ? bound.skillId : null + } + + setHotkey(key: string, skillId: number, side: 'left' | 'right' = 'right'): void { + this.bindHotkey(key, side, skillId) + } + + handleKeyDown(key: string): boolean { + const res = this.triggerFunctionKey(key) + return res !== null + } + + openSpeedbar(side: 'left' | 'right'): void { + this.openPopup = side + } + + closeSpeedbar(): void { + this.openPopup = null + } + + assignSkill(side: 'left' | 'right', skillId: number): void { + if (side === 'left') this.leftSkillId = skillId + else this.rightSkillId = skillId + } + + setDualSkill(skillId: number): void { + this.leftSkillId = skillId + this.rightSkillId = skillId + } + + getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] { + return this.availableSkills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable)) + } + triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null { const key = fKey.toUpperCase() if (this.openPopup !== null && this.hoveredPopupSkill !== null) { @@ -96,14 +190,14 @@ export class SkillHotkeysHud { return bound } - private getPopupCells(side: 'left' | 'right'): { + getPopupCells(side: 'left' | 'right'): { skill: HotkeySkillEntry x: number y: number w: number h: number }[] { - const list = this.availableSkills.filter(s => (side === 'left' ? s.leftUsable : s.rightUsable)) + const list = this.getAvailableSkillsForSide(side) const cols = 5 const cellW = 48 const cellH = 48 @@ -117,13 +211,47 @@ export class SkillHotkeysHud { }) } + getSpeedbarSkills(side: 'left' | 'right'): { + skill: HotkeySkillEntry + x: number + y: number + w: number + h: number + }[] { + return this.getPopupCells(side) + } + handleMouseMove(logicalX: number, logicalY: number): void { + this.hoverSkillId = null + this.hoverSpeedbarSkillId = null this.hoveredPopupSkill = null + + if ( + logicalX >= LEFT_SKILL_BOUNDS.x && + logicalX <= LEFT_SKILL_BOUNDS.x + LEFT_SKILL_BOUNDS.width && + logicalY >= LEFT_SKILL_BOUNDS.y && + logicalY <= LEFT_SKILL_BOUNDS.y + LEFT_SKILL_BOUNDS.height + ) { + this.hoverSkillId = this.leftSkillId + return + } + + if ( + logicalX >= RIGHT_SKILL_BOUNDS.x && + logicalX <= RIGHT_SKILL_BOUNDS.x + RIGHT_SKILL_BOUNDS.width && + logicalY >= RIGHT_SKILL_BOUNDS.y && + logicalY <= RIGHT_SKILL_BOUNDS.y + RIGHT_SKILL_BOUNDS.height + ) { + this.hoverSkillId = this.rightSkillId + return + } + if (!this.openPopup) return const cells = this.getPopupCells(this.openPopup) for (const c of cells) { if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) { this.hoveredPopupSkill = { side: this.openPopup, skillId: c.skill.skillId } + this.hoverSpeedbarSkillId = c.skill.skillId return } } diff --git a/tests/attack-skill.test.ts b/tests/attack-skill.test.ts new file mode 100644 index 0000000..1c3fd25 --- /dev/null +++ b/tests/attack-skill.test.ts @@ -0,0 +1,392 @@ +/** + * Tests for Normal Attack Icon Completion & Dual Slot Binding Mechanics (Issue #151). + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { readFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { + DEFAULT_SORCERESS_SKILLS, + SkillHotkeysHud, + resolveSkillIcon, + getAvailableSkillsForSide, +} from '../src/ui/hotkeys.ts' +import { + getAttackWeaponInfo, + checkAmmunition, + getSkillRange, + castSkill, + type AttackWeaponInputItem, +} from '../src/scene/act-scene.ts' +import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts' +import type { Monster } from '../src/game/combat.ts' + +describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => { + describe('Skill 0 Definition & Dual-Palette Usability', () => { + it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => { + const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0) + expect(attackSkill).toBeDefined() + expect(attackSkill!.name).toBe('Attack') + expect(attackSkill!.nameZh).toBe('普通攻击') + expect(attackSkill!.leftUsable).toBe(true) + expect(attackSkill!.rightUsable).toBe(true) + expect(attackSkill!.manaCost).toBe(0) + }) + + it('includes Skill 0 in both left and right available skill palettes', () => { + const leftSkills = getAvailableSkillsForSide('left') + const rightSkills = getAvailableSkillsForSide('right') + + const leftAttack = leftSkills.find((s) => s.skillId === 0) + const rightAttack = rightSkills.find((s) => s.skillId === 0) + + expect(leftAttack).toBeDefined() + expect(rightAttack).toBeDefined() + expect(leftAttack!.name).toBe('Attack') + expect(rightAttack!.name).toBe('Attack') + }) + + it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => { + const hud = new SkillHotkeysHud() + + // Initial defaults + expect(hud.leftSkillId).toBe(47) // Fire Ball + expect(hud.rightSkillId).toBe(64) // Frozen Orb + + // Assign Attack to left slot + hud.assignSkill('left', 0) + expect(hud.leftSkillId).toBe(0) + expect(hud.rightSkillId).toBe(64) + + // Assign Attack to right slot + hud.assignSkill('right', 0) + expect(hud.leftSkillId).toBe(0) + expect(hud.rightSkillId).toBe(0) + + // Change left slot back to Fire Ball + hud.assignSkill('left', 47) + expect(hud.leftSkillId).toBe(47) + expect(hud.rightSkillId).toBe(0) + + // Use helper setDualSkill + hud.setDualSkill(0) + expect(hud.leftSkillId).toBe(0) + expect(hud.rightSkillId).toBe(0) + }) + }) + + describe('Icon Asset & DC6 Frame 0 Resolution', () => { + it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => { + const iconMapping = resolveSkillIcon(0) + expect(iconMapping).toBeDefined() + expect(iconMapping.skillId).toBe(0) + expect(iconMapping.name).toBe('Attack') + expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6') + expect(iconMapping.frameIndex).toBe(0) + expect(iconMapping.atlasRect.x).toBe(0) + expect(iconMapping.atlasRect.y).toBe(0) + expect(iconMapping.atlasRect.width).toBe(48) + expect(iconMapping.atlasRect.height).toBe(48) + }) + + it('verifies public/skills/icon_0.png exists and is a valid 48x48 PNG image', () => { + const iconPath = join(process.cwd(), 'public', 'skills', 'icon_0.png') + expect(existsSync(iconPath)).toBe(true) + + const buf = readFileSync(iconPath) + // Check PNG magic header: \x89PNG\r\n\x1a\n + expect(buf[0]).toBe(0x89) + expect(buf[1]).toBe(0x50) // P + expect(buf[2]).toBe(0x4e) // N + expect(buf[3]).toBe(0x47) // G + expect(buf[4]).toBe(0x0d) // \r + expect(buf[5]).toBe(0x0a) // \n + expect(buf[6]).toBe(0x1a) + expect(buf[7]).toBe(0x0a) // \n + + // Check IHDR dimensions (width at bytes 16-19, height at bytes 20-23) + const width = buf.readUInt32BE(16) + const height = buf.readUInt32BE(20) + expect(width).toBe(48) + expect(height).toBe(48) + }) + + it('verifies public/ui/icon_0.png exists for baked UI references', () => { + const iconPath = join(process.cwd(), 'public', 'ui', 'icon_0.png') + expect(existsSync(iconPath)).toBe(true) + const buf = readFileSync(iconPath) + expect(buf.readUInt32BE(16)).toBe(48) + expect(buf.readUInt32BE(20)).toBe(48) + }) + }) + + describe('Weapon Timing, Damage, and Ammunition Checks', () => { + it('evaluates unarmed attack characteristics correctly', () => { + const unarmed = getAttackWeaponInfo(null) + expect(unarmed.kind).toBe('unarmed') + expect(unarmed.subtype).toBe('fist') + expect(unarmed.isRanged).toBe(false) + expect(unarmed.damageMin).toBe(1) + expect(unarmed.damageMax).toBe(2) + expect(unarmed.reach).toBe(52) + expect(unarmed.cooldown).toBe(12) + expect(unarmed.swingTicks).toBe(6) + }) + + it('evaluates melee weapon attack timing and damage correctly', () => { + const crystalSword: AttackWeaponInputItem = { + code: 'crs', + name: 'Crystal Sword', + damage: '5 to 15', + } + const swordInfo = getAttackWeaponInfo(crystalSword) + expect(swordInfo.kind).toBe('melee') + expect(swordInfo.subtype).toBe('sword') + expect(swordInfo.isRanged).toBe(false) + expect(swordInfo.damageMin).toBe(5) + expect(swordInfo.damageMax).toBe(15) + expect(swordInfo.reach).toBe(52) + expect(swordInfo.cooldown).toBe(14) + expect(swordInfo.swingTicks).toBe(7) + + // Test extended reach for polearm + const polearm: AttackWeaponInputItem = { + code: 'vou', + name: 'Voulge', + damage: '6 - 21', + } + const polearmInfo = getAttackWeaponInfo(polearm) + expect(polearmInfo.subtype).toBe('polearm') + expect(polearmInfo.reach).toBe(60) + }) + + it('evaluates ranged weapon (bow/crossbow) timing and ammo requirement', () => { + const shortBow: AttackWeaponInputItem = { + code: 'sbw', + name: 'Short Bow', + damage: '1 to 4', + } + const bowInfo = getAttackWeaponInfo(shortBow) + expect(bowInfo.kind).toBe('ranged') + expect(bowInfo.subtype).toBe('bow') + expect(bowInfo.isRanged).toBe(true) + expect(bowInfo.ammoType).toBe('arrow') + expect(bowInfo.reach).toBe(450) + expect(bowInfo.cooldown).toBe(14) + + const lightCrossbow: AttackWeaponInputItem = { + code: 'lxb', + name: 'Light Crossbow', + damage: '6 to 9', + } + const xbowInfo = getAttackWeaponInfo(lightCrossbow) + expect(xbowInfo.kind).toBe('ranged') + expect(xbowInfo.subtype).toBe('crossbow') + expect(xbowInfo.isRanged).toBe(true) + expect(xbowInfo.ammoType).toBe('bolt') + expect(xbowInfo.reach).toBe(450) + expect(xbowInfo.cooldown).toBe(16) + }) + + it('verifies checkAmmunition for bows and crossbows', () => { + // Offhand arrow equipped + expect(checkAmmunition('arrow', { code: 'aqv', name: 'Arrows' })).toBe(true) + // Offhand bolt equipped when bow needs arrow -> false + expect(checkAmmunition('arrow', { code: 'cqv', name: 'Bolts' })).toBe(false) + // Offhand bolt equipped when crossbow needs bolt -> true + expect(checkAmmunition('bolt', { code: 'cqv', name: 'Bolts' })).toBe(true) + + // Ammo in inventory grid + const invWithArrows = { + gridItems: [{ item: { code: 'aqv', nameZh: '箭矢' } }], + } + expect(checkAmmunition('arrow', null, invWithArrows)).toBe(true) + expect(checkAmmunition('bolt', null, invWithArrows)).toBe(false) + + const invWithBolts = { + items: [{ code: 'cqv', nameZh: '十字弓弹' }], + } + expect(checkAmmunition('bolt', null, invWithBolts)).toBe(true) + expect(checkAmmunition('arrow', null, invWithBolts)).toBe(false) + + // Empty inventory & offhand + expect(checkAmmunition('arrow', null, null)).toBe(false) + expect(checkAmmunition('bolt', null, { items: [] })).toBe(false) + }) + }) + + describe('Combat Execution via castSkill', () => { + let engine: GameEngine + let statusElem: HTMLElement + const dummyRuntime: any = { + level: 'test', + grid: { width: 100, height: 100, cells: new Uint8Array(10000) }, + waypoints: [], + } + + beforeEach(() => { + const dummyTerrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + } + const dummyOpts: GameEngineOptions = { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: [0, 100], + itemBases: [], + prefixAffixes: [], + suffixAffixes: [], + skills: [], + npcDefs: [], + questDefs: [], + monsterCount: 0, + combatOptions: { + playerSpeed: 100, + playerReach: 50, + playerCooldownTicks: 10, + playerDamage: 5, + playerManaPerAttack: 0, + respawnTicks: 100, + }, + talkRadius: 80, + pickupRadius: 40, + inventoryCols: 10, + inventoryRows: 4, + } + engine = new GameEngine(dummyTerrain, dummyOpts) + engine.world.player.x = 100 + engine.world.player.y = 100 + engine.world.player.cooldown = 0 + statusElem = { textContent: '' } as any + }) + + it('executes unarmed attack hitting a nearby monster with base physical damage and unarmed timing', () => { + const monster: Monster = { + index: 0, + x: 120, + y: 100, + hp: 50, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'zombie', name: 'Zombie', hp: 50, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 }, + } + engine.world.monsters = [monster] + + const success = castSkill(0, 120, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: null, // unarmed + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(12) + expect(engine.world.player.swingTicks).toBe(6) + expect(monster.hp).toBeLessThan(50) // damaged + expect(engine.metrics.playerHits).toBe(1) + expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中') + }) + + it('executes melee weapon attack with equipped weapon timing and damage', () => { + const monster: Monster = { + index: 0, + x: 130, + y: 100, + hp: 100, + cooldown: 0, + hitFlash: 0, + corpseTicks: 0, + state: 'idle', + facing: 0, + stats: { id: 'skeleton', name: 'Skeleton', hp: 100, damage: 5, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 15 }, + } + engine.world.monsters = [monster] + + const success = castSkill(0, 130, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: { + code: 'crs', + name: 'Crystal Sword', + damage: '20 to 30', + }, + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.world.player.swingTicks).toBe(7) + // Damage should be (20 + 30) / 2 = 25 + expect(monster.hp).toBe(75) + expect(statusElem.textContent).toContain('普通攻击命中') + }) + + it('fails fast on ranged attack when ammunition is missing', () => { + const success = castSkill(0, 200, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: { + code: 'sbw', + name: 'Short Bow', + damage: '1 to 4', + }, + hasAmmo: false, + }) + + expect(success).toBe(false) + expect(engine.projectiles.length).toBe(0) + expect(statusElem.textContent).toBe('缺少箭矢!') + }) + + it('launches basic arrow projectile on ranged attack when ammunition is present', () => { + const success = castSkill(0, 300, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: { + code: 'sbw', + name: 'Short Bow', + damage: '10 to 20', + }, + hasAmmo: true, + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(14) + expect(engine.projectiles.length).toBe(1) + + const proj = engine.projectiles[0] + expect(proj.skillId).toBe('0') + expect(proj.fromPlayer).toBe(true) + expect(proj.damage).toBe(15) // average of 10 and 20 + expect(proj.vx).toBeGreaterThan(0) + expect(statusElem.textContent).toContain('发射箭矢') + }) + + it('records whiff event when melee attack is performed with no monster in reach', () => { + engine.world.monsters = [] // no monsters + + const success = castSkill(0, 120, 100, { + engine, + runtime: dummyRuntime, + hudManager: null, + status: statusElem, + equippedWeapon: null, // unarmed + }) + + expect(success).toBe(true) + expect(engine.world.player.cooldown).toBe(12) + const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff') + expect(whiffEvent).toBeDefined() + }) + }) +}) diff --git a/tests/hotkeys.test.ts b/tests/hotkeys.test.ts new file mode 100644 index 0000000..35c08a6 --- /dev/null +++ b/tests/hotkeys.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for SkillHotkeysHud, speedbars, keybindings, and Attack slot binding (Issue #151). + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { + SkillHotkeysHud, + LEFT_SKILL_BOUNDS, + RIGHT_SKILL_BOUNDS, + DEFAULT_SORCERESS_SKILLS, + resolveSkillIcon, + getAvailableSkillsForSide, +} from '../src/ui/hotkeys.ts' + +describe('SkillHotkeysHud & Dual Slot Mechanics', () => { + let hud: SkillHotkeysHud + + beforeEach(() => { + hud = new SkillHotkeysHud() + }) + + it('initializes with expected default skills and state', () => { + expect(hud.leftSkillId).toBe(47) // Fire Ball + expect(hud.rightSkillId).toBe(64) // Frozen Orb + expect(hud.selectionOpen).toBeNull() + expect(hud.availableSkills.length).toBeGreaterThanOrEqual(10) + + // Skill 0 is available + const attack = hud.availableSkills.find((s) => s.skillId === 0) + expect(attack).toBeDefined() + expect(attack!.leftUsable).toBe(true) + expect(attack!.rightUsable).toBe(true) + }) + + it('opens and closes left selection speedbar on click', () => { + // Click center of left skill icon + const leftX = LEFT_SKILL_BOUNDS.x + 24 + const leftY = LEFT_SKILL_BOUNDS.y + 24 + + const handled = hud.handleClick(leftX, leftY) + expect(handled).toBe(true) + expect(hud.selectionOpen).toBe('left') + + // Click again to close + hud.handleClick(leftX, leftY) + expect(hud.selectionOpen).toBeNull() + }) + + it('opens and closes right selection speedbar on click', () => { + // Click center of right skill icon + const rightX = RIGHT_SKILL_BOUNDS.x + 24 + const rightY = RIGHT_SKILL_BOUNDS.y + 24 + + const handled = hud.handleClick(rightX, rightY) + expect(handled).toBe(true) + expect(hud.selectionOpen).toBe('right') + + // Click outside to close + hud.handleClick(100, 100) + expect(hud.selectionOpen).toBeNull() + }) + + it('allows selecting Normal Attack from left speedbar palette', () => { + hud.openSpeedbar('left') + expect(hud.selectionOpen).toBe('left') + + // Find Attack in speedbar layout + const speedbarSkills = hud.getSpeedbarSkills('left') + const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0) + expect(attackEntry).toBeDefined() + + // Click on Attack icon in speedbar + const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10) + expect(clicked).toBe(true) + expect(hud.leftSkillId).toBe(0) + expect(hud.selectionOpen).toBeNull() // closed after selection + }) + + it('allows selecting Normal Attack from right speedbar palette', () => { + hud.openSpeedbar('right') + expect(hud.selectionOpen).toBe('right') + + const speedbarSkills = hud.getSpeedbarSkills('right') + const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0) + expect(attackEntry).toBeDefined() + + const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10) + expect(clicked).toBe(true) + expect(hud.rightSkillId).toBe(0) + expect(hud.selectionOpen).toBeNull() + }) + + it('supports dual slot binding with Attack on both slots', () => { + hud.assignSkill('left', 0) + hud.assignSkill('right', 0) + + expect(hud.leftSkillId).toBe(0) + expect(hud.rightSkillId).toBe(0) + + // Verify resolveSkillIcon works for both slots + const leftIcon = resolveSkillIcon(hud.leftSkillId) + const rightIcon = resolveSkillIcon(hud.rightSkillId) + + expect(leftIcon.skillId).toBe(0) + expect(rightIcon.skillId).toBe(0) + expect(leftIcon.name).toBe('Attack') + expect(rightIcon.name).toBe('Attack') + expect(leftIcon.iconPath).toBe('/skills/icon_0.png') + expect(rightIcon.iconPath).toBe('/skills/icon_0.png') + }) + + it('manages F1-F8 hotkey bindings and key presses', () => { + // Bind F1 to Attack (0) + hud.setHotkey('F1', 0) + expect(hud.getHotkey('F1')).toBe(0) + + // Bind F2 to Frozen Orb (64) + hud.setHotkey('F2', 64) + expect(hud.getHotkey('F2')).toBe(64) + + // Pressing F1 assigns right skill to 0 + const handledF1 = hud.handleKeyDown('F1') + expect(handledF1).toBe(true) + expect(hud.rightSkillId).toBe(0) + + // Pressing F2 assigns right skill to 64 + const handledF2 = hud.handleKeyDown('F2') + expect(handledF2).toBe(true) + expect(hud.rightSkillId).toBe(64) + }) + + it('handles mouse hover over skill buttons and speedbar icons', () => { + hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10) + expect(hud.hoverSkillId).toBe(hud.leftSkillId) + + hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10) + expect(hud.hoverSkillId).toBe(hud.rightSkillId) + + hud.openSpeedbar('left') + const speedbarSkills = hud.getSpeedbarSkills('left') + const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0) + expect(attackEntry).toBeDefined() + + hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10) + expect(hud.hoverSpeedbarSkillId).toBe(0) + }) +})