feat(skills): add normal attack icon and enable dual left/right slot binding (Fixes #151)
This commit is contained in:
commit
1f1996f65b
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -373,6 +373,22 @@ async function main(): Promise<void> {
|
|||
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<void> {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -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,9 +1379,19 @@ export class SceneMouseController {
|
|||
}
|
||||
} else if (e.button === 2) {
|
||||
this.isRightMouseDown = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly onPointerMove = (e: PointerEvent): void => {
|
||||
this.lastClientPos = { x: e.clientX, y: e.clientY }
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { side: 'left' | 'right'; skillId: number }>([
|
||||
['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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue