diablo2-web/src/arena.ts

1843 lines
72 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Standalone Combat Mathematics & Attack/Magic Testing Arena (Issue #24 / M9).
*
* Renders 1 Player and 1 Monster in an isometric testing duel ring.
* Allows interactive configuration of character attributes, monster types, and levels.
* Provides real-time execution and step-by-step mathematical breakdown for:
* - Melee physical attacks (AR vs DR to-hit, Shield Block, Critical/Deadly Strike, Crushing Blow, DR%)
* - Fire, Cold, Lightning, Poison, and Magic spells (resistances, immunities, MDR, chill/stun effects)
* - Monster counter-attacks, Hit Recovery (FHR), and continuous DPS benchmarks.
*/
import {
calculateAttack,
calculateHitChance,
calculatePlayerBlockChance,
calculateCriticalChance,
evaluateCriticalAndDeadlyStrike,
calculateCrushingBlowDamage,
calculateOpenWoundsDamage,
calculateLeech,
calculateFreezeTarget,
calculateResistanceFactor,
calculateResistanceReduction,
calculateMonsterRegeneration,
evaluatePreventMonsterHeal,
calculateKnockbackSuccess,
calculateSlowTarget,
calculateHitBlindsTarget,
calculateHitCausesFlee,
calculatePhysicalWeaponDamage,
calculatePoisonStack,
checkHitRecovery,
D2_EXPERIENCE_TABLE_99,
} from './game/formulas.ts'
import type { CombatCalculationResult, TargetTier, GameDifficulty, MonsterSize } from './game/formulas.ts'
// --- Monster Database & Scaling ---------------------------------------------
interface MonsterBaseDefinition {
readonly id: string
readonly name: string
readonly category: string
readonly baseMinHp: number
readonly baseMaxHp: number
readonly baseAc: number
readonly baseTh: number
readonly baseMinD: number
readonly baseMaxD: number
readonly baseExp: number
readonly blockPercent: number
readonly resPhysical: number
readonly resFire: number
readonly resLightning: number
readonly resCold: number
readonly resPoison: number
readonly resMagic: number
readonly color: string
readonly tier?: TargetTier
readonly drainEffectiveness?: number
readonly size?: MonsterSize
readonly regenRate?: number
}
const MONSTER_TEMPLATES: Record<string, MonsterBaseDefinition> = {
fallen1: {
id: 'fallen1', name: '沉沦魔 (Fallen)', category: '恶魔 (Demon)',
baseMinHp: 21, baseMaxHp: 61, baseAc: 84, baseTh: 101, baseMinD: 51, baseMaxD: 101, baseExp: 61,
blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
color: '#e64a19', tier: 'normal', drainEffectiveness: 100, size: 'small', regenRate: 0,
},
zombie1: {
id: 'zombie1', name: '僵尸 (Zombie)', category: '不死 (Undead)',
baseMinHp: 31, baseMaxHp: 61, baseAc: 64, baseTh: 81, baseMinD: 51, baseMaxD: 101, baseExp: 111,
blockPercent: 0, resPhysical: 0, resFire: -20, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
color: '#558b2f', tier: 'normal', drainEffectiveness: 50, size: 'medium', regenRate: 5,
},
quillrat1: {
id: 'quillrat1', name: '刺鼠 (Quill Rat)', category: '动物 (Animal)',
baseMinHp: 21, baseMaxHp: 41, baseAc: 64, baseTh: 101, baseMinD: 51, baseMaxD: 101, baseExp: 71,
blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
color: '#8d6e63', tier: 'normal', drainEffectiveness: 100, size: 'small', regenRate: 0,
},
skeleton1: {
id: 'skeleton1', name: '骷髅战士 (Skeleton)', category: '不死 (Undead)',
baseMinHp: 21, baseMaxHp: 51, baseAc: 84, baseTh: 81, baseMinD: 51, baseMaxD: 101, baseExp: 81,
blockPercent: 15, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 25, resMagic: 0,
color: '#cfd8dc', tier: 'normal', drainEffectiveness: 0, size: 'medium', regenRate: 0,
},
corruptrogue1: {
id: 'corruptrogue1', name: '黑暗猎手 (Dark Hunter)', category: '恶魔 (Demon)',
baseMinHp: 25, baseMaxHp: 55, baseAc: 101, baseTh: 121, baseMinD: 61, baseMaxD: 111, baseExp: 91,
blockPercent: 20, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
color: '#7b1fa2', tier: 'normal', drainEffectiveness: 100, size: 'medium', regenRate: 0,
},
goatman1: {
id: 'goatman1', name: '月亮氏族羊头人 (Moon Clan)', category: '动物 (Animal)',
baseMinHp: 41, baseMaxHp: 81, baseAc: 101, baseTh: 101, baseMinD: 81, baseMaxD: 141, baseExp: 131,
blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 20, resPoison: 0, resMagic: 0,
color: '#4e342e', tier: 'normal', drainEffectiveness: 100, size: 'large', regenRate: 0,
},
bloodraven: {
id: 'bloodraven', name: '血鸟 (Blood Raven)', category: '暗金Boss (SuperUnique)',
baseMinHp: 120, baseMaxHp: 200, baseAc: 150, baseTh: 180, baseMinD: 100, baseMaxD: 180, baseExp: 500,
blockPercent: 30, resPhysical: 10, resFire: 20, resLightning: 20, resCold: 20, resPoison: 20, resMagic: 0,
color: '#c2185b', tier: 'boss', drainEffectiveness: 50, size: 'medium', regenRate: 8,
},
andariel: {
id: 'andariel', name: '安达利尔 (Andariel)', category: '幕终首领 (Act Boss)',
baseMinHp: 1025, baseMaxHp: 1200, baseAc: 175, baseTh: 220, baseMinD: 150, baseMaxD: 260, baseExp: 3200,
blockPercent: 0, resPhysical: 15, resFire: -50, resLightning: 50, resCold: 50, resPoison: 80, resMagic: 0,
color: '#2e7d32', tier: 'boss', drainEffectiveness: 100, size: 'large', regenRate: 15,
},
duriel: {
id: 'duriel', name: '督瑞尔 (Duriel)', category: '幕终首领 (Act Boss)',
baseMinHp: 3995, baseMaxHp: 4500, baseAc: 300, baseTh: 350, baseMinD: 250, baseMaxD: 400, baseExp: 8500,
blockPercent: 0, resPhysical: 20, resFire: 20, resLightning: 20, resCold: 50, resPoison: 20, resMagic: 0,
color: '#00838f', tier: 'boss', drainEffectiveness: 100, size: 'large', regenRate: 20,
},
mephisto: {
id: 'mephisto', name: '墨菲斯托 (Mephisto)', category: '幕终首领 (Act Boss)',
baseMinHp: 7300, baseMaxHp: 8000, baseAc: 450, baseTh: 500, baseMinD: 350, baseMaxD: 550, baseExp: 18000,
blockPercent: 25, resPhysical: 20, resFire: 50, resLightning: 75, resCold: 50, resPoison: 75, resMagic: 0,
color: '#1565c0', tier: 'boss', drainEffectiveness: 20, size: 'large', regenRate: 25,
},
diablo: {
id: 'diablo', name: '暗黑破坏神 (Diablo)', category: '幕终首领 (Act Boss)',
baseMinHp: 13800, baseMaxHp: 15000, baseAc: 600, baseTh: 700, baseMinD: 500, baseMaxD: 800, baseExp: 35000,
blockPercent: 30, resPhysical: 30, resFire: 50, resLightning: 50, resCold: 50, resPoison: 50, resMagic: 0,
color: '#d32f2f', tier: 'boss', drainEffectiveness: 50, size: 'large', regenRate: 35,
},
baal: {
id: 'baal', name: '毁灭之王巴尔 (Baal)', category: '幕终首领 (Act Boss)',
baseMinHp: 26000, baseMaxHp: 30000, baseAc: 800, baseTh: 900, baseMinD: 600, baseMaxD: 1000, baseExp: 60000,
blockPercent: 40, resPhysical: 50, resFire: 50, resLightning: 50, resCold: 50, resPoison: 50, resMagic: 0,
color: '#ff8f00', tier: 'boss', drainEffectiveness: 33, size: 'large', regenRate: 45,
},
dummy: {
id: 'dummy', name: '测伤木桩 (Training Dummy)', category: '无敌靶桩 (Dummy)',
baseMinHp: 100000, baseMaxHp: 100000, baseAc: 100, baseTh: 100, baseMinD: 10, baseMaxD: 20, baseExp: 100,
blockPercent: 0, resPhysical: 0, resFire: 0, resLightning: 0, resCold: 0, resPoison: 0, resMagic: 0,
color: '#ffb300', tier: 'normal', drainEffectiveness: 100, size: 'large', regenRate: 0,
},
}
/** Scale multipliers derived from MonLvl.txt per level */
function computeMonsterScale(level: number) {
const l = Math.max(1, Math.min(99, level))
return {
ac: Math.round(6 + 2.2 * l),
th: Math.round(8 + 2.8 * l),
hp: Math.round(7 + 3.0 * l),
dm: Math.round(2 + 1.2 * l),
xp: Math.round(30 + 10 * l),
}
}
// --- Visual & Particle FX State ---------------------------------------------
interface FloatingText {
x: number
y: number
text: string
color: string
size: number
alpha: number
vy: number
life: number
}
interface ProjectileFx {
x: number
y: number
targetX: number
targetY: number
type: 'fire' | 'cold' | 'light' | 'magic'
progress: number
speed: number
onHit: () => void
}
interface ParticleFx {
x: number
y: number
vx: number
vy: number
color: string
size: number
life: number
maxLife: number
}
// --- App State --------------------------------------------------------------
class CombatArenaApp {
private canvas!: HTMLCanvasElement
private ctx!: CanvasRenderingContext2D
// Player State
private player = {
class: 'Sorceress',
level: 30,
hp: 350,
maxHp: 350,
mana: 200,
maxMana: 200,
ar: 1200,
dr: 650,
dmgMin: 25,
dmgMax: 65,
fire: 15,
cold: 10,
light: 25,
poison: 0,
magic: 0,
block: 45,
cs: 20,
ds: 15,
cb: 25,
ow: 25,
ll: 10,
ml: 5,
itd: false,
defRed: 25,
freezeBonus: 1,
knockback: 25,
difficulty: 'normal' as GameDifficulty,
flatDr: 2,
flatMdr: 2,
fhr: 30,
// PvE mechanics
pmh: true,
isMerc: false,
conviction: 0,
lowerRes: 0,
minusLightRes: 0,
minusFireRes: 0,
minusColdRes: 0,
minusPoisonRes: 0,
slow: 0,
hbt: 0,
hcmtf: 0,
flatDmg: 0,
offWeaponEd: 0,
statBonus: 0,
// Animation state
action: 'idle' as 'idle' | 'attack' | 'cast' | 'hit' | 'dead',
actionTimer: 0,
hitFlash: 0,
}
// Monster State
private monster = {
typeKey: 'fallen1',
name: '沉沦魔 (Fallen)',
category: '恶魔 (Demon)',
tier: 'normal' as TargetTier,
posture: 'stand' as 'stand' | 'run',
level: 28,
hp: 180,
maxHp: 180,
dr: 480,
ar: 520,
dmgMin: 12,
dmgMax: 28,
block: 0,
drainEffectiveness: 100,
resPhysical: 0,
resFire: 0,
resCold: 0,
resLightning: 0,
resPoison: 0,
resMagic: 0,
flatDr: 0,
flatMdr: 0,
size: 'medium' as MonsterSize,
regenRate: 0,
isUber: false,
// Animation & Status state
action: 'idle' as 'idle' | 'attack' | 'hit' | 'dead',
actionTimer: 0,
hitFlash: 0,
color: '#e64a19',
stunTimer: 0,
chillTimer: 0,
freezeTimer: 0,
openWoundsTimer: 0,
openWoundsDmgPerFrame: 0,
knockbackOffset: 0,
pmhTimer: 0,
blindTimer: 0,
fleeTimer: 0,
slowTimer: 0,
slowPercent: 0,
}
// Visuals
private floatingTexts: FloatingText[] = []
private projectiles: ProjectileFx[] = []
private particles: ParticleFx[] = []
private autoBattleActive = false
private autoBattleInterval: number | null = null
// Statistics
private totalDamageDealt = 0
private combatStartTime = 0
constructor() {
this.initDom()
this.initEvents()
this.updateMonsterStatsFromTypeAndLevel()
this.startRenderLoop()
}
private initDom() {
this.canvas = document.getElementById('view') as HTMLCanvasElement
this.ctx = this.canvas.getContext('2d')!
this.resizeCanvas()
window.addEventListener('resize', () => this.resizeCanvas())
}
private resizeCanvas() {
const wrapper = document.getElementById('viewport-wrapper')!
this.canvas.width = wrapper.clientWidth
this.canvas.height = wrapper.clientHeight
}
private initEvents() {
// Player presets
const presetSelect = document.getElementById('char-preset') as HTMLSelectElement
presetSelect.addEventListener('change', () => this.applyPlayerPreset(presetSelect.value))
// Player inputs sync
const syncPlayerInputs = () => {
this.player.class = (document.getElementById('char-class') as HTMLSelectElement).value
this.player.level = Number((document.getElementById('char-level') as HTMLInputElement).value) || 1
this.player.hp = Number((document.getElementById('char-hp') as HTMLInputElement).value) || 1
this.player.maxHp = this.player.hp
this.player.mana = Number((document.getElementById('char-mana') as HTMLInputElement).value) || 0
this.player.maxMana = this.player.mana
this.player.ar = Number((document.getElementById('char-ar') as HTMLInputElement).value) || 0
this.player.dr = Number((document.getElementById('char-dr') as HTMLInputElement).value) || 0
this.player.dmgMin = Number((document.getElementById('char-dmg-min') as HTMLInputElement).value) || 1
this.player.dmgMax = Number((document.getElementById('char-dmg-max') as HTMLInputElement).value) || 1
this.player.fire = Number((document.getElementById('char-fire') as HTMLInputElement).value) || 0
this.player.cold = Number((document.getElementById('char-cold') as HTMLInputElement).value) || 0
this.player.light = Number((document.getElementById('char-light') as HTMLInputElement).value) || 0
this.player.poison = Number((document.getElementById('char-poison') as HTMLInputElement).value) || 0
this.player.magic = Number((document.getElementById('char-magic') as HTMLInputElement).value) || 0
this.player.block = Number((document.getElementById('char-block') as HTMLInputElement).value) || 0
this.player.cs = Number((document.getElementById('char-cs') as HTMLInputElement)?.value) || 0
this.player.ds = Number((document.getElementById('char-ds') as HTMLInputElement)?.value) || 0
this.player.cb = Number((document.getElementById('char-cb') as HTMLInputElement)?.value) || 0
this.player.ow = Number((document.getElementById('char-ow') as HTMLInputElement)?.value) || 0
this.player.ll = Number((document.getElementById('char-ll') as HTMLInputElement)?.value) || 0
this.player.ml = Number((document.getElementById('char-ml') as HTMLInputElement)?.value) || 0
this.player.itd = (document.getElementById('char-itd') as HTMLInputElement)?.checked ?? false
this.player.defRed = Number((document.getElementById('char-def-red') as HTMLInputElement)?.value) || 0
this.player.freezeBonus = Number((document.getElementById('char-freeze') as HTMLInputElement)?.value) || 0
this.player.knockback = Number((document.getElementById('char-knockback') as HTMLInputElement)?.value) || 0
this.player.difficulty = ((document.getElementById('game-difficulty') as HTMLSelectElement)?.value ?? 'normal') as GameDifficulty
this.player.flatDr = Number((document.getElementById('char-flat-dr') as HTMLInputElement).value) || 0
this.player.flatMdr = Number((document.getElementById('char-flat-mdr') as HTMLInputElement).value) || 0
this.player.pmh = (document.getElementById('char-pmh') as HTMLInputElement)?.checked ?? false
this.player.isMerc = (document.getElementById('char-is-merc') as HTMLInputElement)?.checked ?? false
this.player.conviction = Number((document.getElementById('char-conviction') as HTMLInputElement)?.value) || 0
this.player.lowerRes = Number((document.getElementById('char-lower-res') as HTMLInputElement)?.value) || 0
this.player.minusLightRes = Number((document.getElementById('char-minus-light-res') as HTMLInputElement)?.value) || 0
this.player.minusFireRes = Number((document.getElementById('char-minus-fire-res') as HTMLInputElement)?.value) || 0
this.player.minusColdRes = Number((document.getElementById('char-minus-cold-res') as HTMLInputElement)?.value) || 0
this.player.minusPoisonRes = Number((document.getElementById('char-minus-poison-res') as HTMLInputElement)?.value) || 0
this.player.slow = Number((document.getElementById('char-slow') as HTMLInputElement)?.value) || 0
this.player.hbt = Number((document.getElementById('char-hbt') as HTMLInputElement)?.value) || 0
this.player.hcmtf = Number((document.getElementById('char-hcmtf') as HTMLInputElement)?.value) || 0
this.player.flatDmg = Number((document.getElementById('char-flat-dmg') as HTMLInputElement)?.value) || 0
this.player.offWeaponEd = Number((document.getElementById('char-off-weapon-ed') as HTMLInputElement)?.value) || 0
this.player.statBonus = Number((document.getElementById('char-stat-bonus') as HTMLInputElement)?.value) || 0
}
const playerInputIds = [
'char-class', 'char-level', 'char-hp', 'char-mana', 'char-ar', 'char-dr',
'char-dmg-min', 'char-dmg-max', 'char-fire', 'char-cold', 'char-light',
'char-poison', 'char-magic', 'char-block', 'char-cs', 'char-ds', 'char-cb', 'char-ow',
'char-ll', 'char-ml', 'char-itd', 'char-def-red', 'char-freeze', 'char-knockback',
'game-difficulty', 'char-flat-dr', 'char-flat-mdr',
'char-pmh', 'char-is-merc', 'char-conviction', 'char-lower-res',
'char-minus-light-res', 'char-minus-fire-res', 'char-minus-cold-res', 'char-minus-poison-res',
'char-slow', 'char-hbt', 'char-hcmtf', 'char-flat-dmg', 'char-off-weapon-ed', 'char-stat-bonus',
]
for (const id of playerInputIds) {
const el = document.getElementById(id)
el?.addEventListener('input', syncPlayerInputs)
el?.addEventListener('change', syncPlayerInputs)
}
// Monster Type & Level
const monTypeSelect = document.getElementById('mon-type') as HTMLSelectElement
const monLevelInput = document.getElementById('mon-level') as HTMLInputElement
monTypeSelect.addEventListener('change', () => this.updateMonsterStatsFromTypeAndLevel())
monLevelInput.addEventListener('input', () => this.updateMonsterStatsFromTypeAndLevel())
// Monster custom inputs
const syncMonsterInputs = () => {
this.monster.tier = ((document.getElementById('mon-tier') as HTMLSelectElement)?.value ?? 'normal') as TargetTier
this.monster.posture = ((document.getElementById('mon-posture') as HTMLSelectElement)?.value ?? 'stand') as 'stand' | 'run'
this.monster.drainEffectiveness = Number((document.getElementById('mon-drain-eff') as HTMLInputElement)?.value) ?? 100
this.monster.size = ((document.getElementById('mon-size') as HTMLSelectElement)?.value ?? 'medium') as MonsterSize
this.monster.regenRate = Number((document.getElementById('mon-regen') as HTMLInputElement)?.value) || 0
this.monster.isUber = (document.getElementById('mon-is-uber') as HTMLInputElement)?.checked ?? false
this.monster.hp = Number((document.getElementById('mon-hp') as HTMLInputElement).value) || 1
this.monster.maxHp = Number((document.getElementById('mon-max-hp') as HTMLInputElement).value) || 1
this.monster.dr = Number((document.getElementById('mon-dr') as HTMLInputElement).value) || 0
this.monster.ar = Number((document.getElementById('mon-ar') as HTMLInputElement).value) || 0
this.monster.dmgMin = Number((document.getElementById('mon-dmg-min') as HTMLInputElement).value) || 1
this.monster.dmgMax = Number((document.getElementById('mon-dmg-max') as HTMLInputElement).value) || 1
this.monster.block = Number((document.getElementById('mon-block') as HTMLInputElement).value) || 0
this.monster.resPhysical = Number((document.getElementById('mon-res-phys') as HTMLInputElement).value) || 0
this.monster.resFire = Number((document.getElementById('mon-res-fire') as HTMLInputElement).value) || 0
this.monster.resCold = Number((document.getElementById('mon-res-cold') as HTMLInputElement).value) || 0
this.monster.resLightning = Number((document.getElementById('mon-res-light') as HTMLInputElement).value) || 0
this.monster.resPoison = Number((document.getElementById('mon-res-poison') as HTMLInputElement).value) || 0
this.monster.resMagic = Number((document.getElementById('mon-res-magic') as HTMLInputElement).value) || 0
this.monster.flatDr = Number((document.getElementById('mon-flat-dr') as HTMLInputElement).value) || 0
this.monster.flatMdr = Number((document.getElementById('mon-flat-mdr') as HTMLInputElement).value) || 0
this.updateMonsterBadge()
}
const monsterInputIds = [
'mon-tier', 'mon-posture', 'mon-drain-eff', 'mon-size', 'mon-regen', 'mon-is-uber',
'mon-hp', 'mon-max-hp', 'mon-dr', 'mon-ar', 'mon-dmg-min', 'mon-dmg-max',
'mon-block', 'mon-res-phys', 'mon-res-fire', 'mon-res-cold', 'mon-res-light',
'mon-res-poison', 'mon-res-magic', 'mon-flat-dr', 'mon-flat-mdr',
]
for (const id of monsterInputIds) {
const el = document.getElementById(id)
el?.addEventListener('input', syncMonsterInputs)
el?.addEventListener('change', syncMonsterInputs)
}
// Buttons
document.getElementById('btn-attack')?.addEventListener('click', () => this.executeMeleeAttack())
document.getElementById('btn-fire')?.addEventListener('click', () => this.castFireSpell())
document.getElementById('btn-cold')?.addEventListener('click', () => this.castColdSpell())
document.getElementById('btn-light')?.addEventListener('click', () => this.castLightningSpell())
document.getElementById('btn-magic')?.addEventListener('click', () => this.castMagicSpell())
document.getElementById('btn-monster-attack')?.addEventListener('click', () => this.executeMonsterAttack())
document.getElementById('btn-auto-battle')?.addEventListener('click', () => this.toggleAutoBattle())
document.getElementById('btn-reset')?.addEventListener('click', () => this.resetCombat())
document.getElementById('btn-clear-log')?.addEventListener('click', () => {
const container = document.getElementById('log-entries')!
container.innerHTML = ''
})
// Keyboard Shortcuts
window.addEventListener('keydown', (e) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement) return
if (e.key === ' ' || e.key === '1') {
e.preventDefault()
this.executeMeleeAttack()
} else if (e.key === '2') {
e.preventDefault()
this.castFireSpell()
} else if (e.key === '3') {
e.preventDefault()
this.castColdSpell()
} else if (e.key === '4') {
e.preventDefault()
this.castLightningSpell()
} else if (e.key === '5') {
e.preventDefault()
this.castMagicSpell()
} else if (e.key === 'a' || e.key === 'A') {
e.preventDefault()
this.toggleAutoBattle()
} else if (e.key === 'r' || e.key === 'R') {
e.preventDefault()
this.resetCombat()
}
})
}
// --- Presets & Data Sync --------------------------------------------------
private applyPlayerPreset(preset: string) {
if (preset === 'novice') {
this.setPlayerForm({
class: 'Barbarian', level: 1, hp: 45, mana: 20, ar: 110, dr: 35,
dmgMin: 2, dmgMax: 6, fire: 0, cold: 0, light: 0, poison: 0, magic: 0,
block: 10, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
freezeBonus: 0, knockback: 0, difficulty: 'normal', flatDr: 0, flatMdr: 0,
pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
})
} else if (preset === 'zealot') {
this.setPlayerForm({
class: 'Paladin', level: 45, hp: 550, mana: 120, ar: 3800, dr: 2200,
dmgMin: 45, dmgMax: 110, fire: 20, cold: 15, light: 40, poison: 0, magic: 0,
block: 75, cs: 15, ds: 35, cb: 50, ow: 33, ll: 8, ml: 4, itd: true, defRed: 25,
freezeBonus: 1, knockback: 0, difficulty: 'nightmare', flatDr: 15, flatMdr: 10,
pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
slow: 50, hbt: 0, hcmtf: 0, flatDmg: 400, offWeaponEd: 300, statBonus: 150,
})
} else if (preset === 'sorc_fire') {
this.setPlayerForm({
class: 'Sorceress', level: 75, hp: 620, mana: 750, ar: 400, dr: 950,
dmgMin: 10, dmgMax: 25, fire: 1450, cold: 0, light: 0, poison: 0, magic: 0,
block: 20, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
freezeBonus: 0, knockback: 0, difficulty: 'hell', flatDr: 5, flatMdr: 25,
pmh: true, isMerc: false, conviction: 85, lowerRes: 60,
minusLightRes: 0, minusFireRes: 20, minusColdRes: 0, minusPoisonRes: 0,
slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
})
} else if (preset === 'sorc_orb') {
this.setPlayerForm({
class: 'Sorceress', level: 80, hp: 700, mana: 800, ar: 500, dr: 1100,
dmgMin: 12, dmgMax: 30, fire: 350, cold: 680, light: 0, poison: 0, magic: 0,
block: 25, cs: 0, ds: 0, cb: 0, ow: 0, ll: 0, ml: 0, itd: false, defRed: 0,
freezeBonus: 3, knockback: 0, difficulty: 'hell', flatDr: 8, flatMdr: 30,
pmh: true, isMerc: false, conviction: 85, lowerRes: 0,
minusLightRes: 0, minusFireRes: 0, minusColdRes: 35, minusPoisonRes: 0,
slow: 0, hbt: 0, hcmtf: 0, flatDmg: 0, offWeaponEd: 0, statBonus: 0,
})
} else if (preset === 'hell_slayer') {
this.setPlayerForm({
class: 'Barbarian', level: 90, hp: 1650, mana: 260, ar: 11500, dr: 7500,
dmgMin: 180, dmgMax: 420, fire: 80, cold: 60, light: 120, poison: 100, magic: 0,
block: 75, cs: 28, ds: 65, cb: 75, ow: 50, ll: 15, ml: 8, itd: true, defRed: 50,
freezeBonus: 2, knockback: 25, difficulty: 'hell', flatDr: 35, flatMdr: 25,
pmh: true, isMerc: false, conviction: 0, lowerRes: 0,
minusLightRes: 0, minusFireRes: 0, minusColdRes: 0, minusPoisonRes: 0,
slow: 35, hbt: 2, hcmtf: 15, flatDmg: 120, offWeaponEd: 450, statBonus: 220,
})
}
}
private setPlayerForm(p: any) {
;(document.getElementById('char-class') as HTMLSelectElement).value = p.class
;(document.getElementById('char-level') as HTMLInputElement).value = String(p.level)
;(document.getElementById('char-hp') as HTMLInputElement).value = String(p.hp)
;(document.getElementById('char-mana') as HTMLInputElement).value = String(p.mana)
;(document.getElementById('char-ar') as HTMLInputElement).value = String(p.ar)
;(document.getElementById('char-dr') as HTMLInputElement).value = String(p.dr)
;(document.getElementById('char-dmg-min') as HTMLInputElement).value = String(p.dmgMin)
;(document.getElementById('char-dmg-max') as HTMLInputElement).value = String(p.dmgMax)
;(document.getElementById('char-fire') as HTMLInputElement).value = String(p.fire)
;(document.getElementById('char-cold') as HTMLInputElement).value = String(p.cold)
;(document.getElementById('char-light') as HTMLInputElement).value = String(p.light)
;(document.getElementById('char-poison') as HTMLInputElement).value = String(p.poison)
;(document.getElementById('char-magic') as HTMLInputElement).value = String(p.magic)
;(document.getElementById('char-block') as HTMLInputElement).value = String(p.block)
;(document.getElementById('char-cs') as HTMLInputElement).value = String(p.cs ?? 0)
;(document.getElementById('char-ds') as HTMLInputElement).value = String(p.ds ?? 0)
;(document.getElementById('char-cb') as HTMLInputElement).value = String(p.cb ?? 0)
;(document.getElementById('char-ow') as HTMLInputElement).value = String(p.ow ?? 0)
;(document.getElementById('char-ll') as HTMLInputElement).value = String(p.ll ?? 0)
;(document.getElementById('char-ml') as HTMLInputElement).value = String(p.ml ?? 0)
;(document.getElementById('char-itd') as HTMLInputElement).checked = Boolean(p.itd)
;(document.getElementById('char-def-red') as HTMLInputElement).value = String(p.defRed ?? 0)
;(document.getElementById('char-freeze') as HTMLInputElement).value = String(p.freezeBonus ?? 0)
;(document.getElementById('char-knockback') as HTMLInputElement).value = String(p.knockback ?? 0)
;(document.getElementById('game-difficulty') as HTMLSelectElement).value = p.difficulty ?? 'normal'
;(document.getElementById('char-flat-dr') as HTMLInputElement).value = String(p.flatDr)
;(document.getElementById('char-flat-mdr') as HTMLInputElement).value = String(p.flatMdr)
const charPmh = document.getElementById('char-pmh') as HTMLInputElement | null
if (charPmh) charPmh.checked = Boolean(p.pmh ?? true)
const charMerc = document.getElementById('char-is-merc') as HTMLInputElement | null
if (charMerc) charMerc.checked = Boolean(p.isMerc ?? false)
const charConv = document.getElementById('char-conviction') as HTMLInputElement | null
if (charConv) charConv.value = String(p.conviction ?? 0)
const charLr = document.getElementById('char-lower-res') as HTMLInputElement | null
if (charLr) charLr.value = String(p.lowerRes ?? 0)
const charMinL = document.getElementById('char-minus-light-res') as HTMLInputElement | null
if (charMinL) charMinL.value = String(p.minusLightRes ?? 0)
const charMinF = document.getElementById('char-minus-fire-res') as HTMLInputElement | null
if (charMinF) charMinF.value = String(p.minusFireRes ?? 0)
const charMinC = document.getElementById('char-minus-cold-res') as HTMLInputElement | null
if (charMinC) charMinC.value = String(p.minusColdRes ?? 0)
const charMinP = document.getElementById('char-minus-poison-res') as HTMLInputElement | null
if (charMinP) charMinP.value = String(p.minusPoisonRes ?? 0)
const charSlow = document.getElementById('char-slow') as HTMLInputElement | null
if (charSlow) charSlow.value = String(p.slow ?? 0)
const charHbt = document.getElementById('char-hbt') as HTMLInputElement | null
if (charHbt) charHbt.value = String(p.hbt ?? 0)
const charHcmtf = document.getElementById('char-hcmtf') as HTMLInputElement | null
if (charHcmtf) charHcmtf.value = String(p.hcmtf ?? 0)
const charFlatD = document.getElementById('char-flat-dmg') as HTMLInputElement | null
if (charFlatD) charFlatD.value = String(p.flatDmg ?? 0)
const charOffEd = document.getElementById('char-off-weapon-ed') as HTMLInputElement | null
if (charOffEd) charOffEd.value = String(p.offWeaponEd ?? 0)
const charStatB = document.getElementById('char-stat-bonus') as HTMLInputElement | null
if (charStatB) charStatB.value = String(p.statBonus ?? 0)
Object.assign(this.player, p)
this.player.maxHp = p.hp
this.player.maxMana = p.mana
}
private updateMonsterStatsFromTypeAndLevel() {
const typeKey = (document.getElementById('mon-type') as HTMLSelectElement).value
const level = Number((document.getElementById('mon-level') as HTMLInputElement).value) || 1
const t = MONSTER_TEMPLATES[typeKey] ?? MONSTER_TEMPLATES['fallen1']!
const scale = computeMonsterScale(level)
const hp = Math.max(1, Math.round(((t.baseMinHp + t.baseMaxHp) / 2) * (scale.hp / 100)))
const dr = Math.max(1, Math.round(t.baseAc * (scale.ac / 100)))
const ar = Math.max(1, Math.round(t.baseTh * (scale.th / 100)))
const dmgMin = Math.max(1, Math.round(t.baseMinD * (scale.dm / 100)))
const dmgMax = Math.max(dmgMin + 1, Math.round(t.baseMaxD * (scale.dm / 100)))
this.monster.typeKey = t.id
this.monster.name = t.name
this.monster.category = t.category
this.monster.level = level
this.monster.hp = hp
this.monster.maxHp = hp
this.monster.dr = dr
this.monster.ar = ar
this.monster.dmgMin = dmgMin
this.monster.dmgMax = dmgMax
this.monster.block = t.blockPercent
this.monster.resPhysical = t.resPhysical
this.monster.resFire = t.resFire
this.monster.resCold = t.resCold
this.monster.resLightning = t.resLightning
this.monster.resPoison = t.resPoison
this.monster.resMagic = t.resMagic
this.monster.color = t.color
this.monster.size = t.size ?? 'medium'
this.monster.regenRate = t.regenRate ?? 0
;(document.getElementById('mon-hp') as HTMLInputElement).value = String(hp)
;(document.getElementById('mon-max-hp') as HTMLInputElement).value = String(hp)
;(document.getElementById('mon-dr') as HTMLInputElement).value = String(dr)
;(document.getElementById('mon-ar') as HTMLInputElement).value = String(ar)
;(document.getElementById('mon-dmg-min') as HTMLInputElement).value = String(dmgMin)
;(document.getElementById('mon-dmg-max') as HTMLInputElement).value = String(dmgMax)
;(document.getElementById('mon-block') as HTMLInputElement).value = String(t.blockPercent)
;(document.getElementById('mon-res-phys') as HTMLInputElement).value = String(t.resPhysical)
;(document.getElementById('mon-res-fire') as HTMLInputElement).value = String(t.resFire)
;(document.getElementById('mon-res-cold') as HTMLInputElement).value = String(t.resCold)
;(document.getElementById('mon-res-light') as HTMLInputElement).value = String(t.resLightning)
;(document.getElementById('mon-res-poison') as HTMLInputElement).value = String(t.resPoison)
;(document.getElementById('mon-res-magic') as HTMLInputElement).value = String(t.resMagic)
const monSizeEl = document.getElementById('mon-size') as HTMLSelectElement | null
if (monSizeEl) monSizeEl.value = this.monster.size
const monRegenEl = document.getElementById('mon-regen') as HTMLInputElement | null
if (monRegenEl) monRegenEl.value = String(this.monster.regenRate)
this.monster.tier = (t.tier ?? 'normal') as TargetTier
this.monster.drainEffectiveness = t.drainEffectiveness ?? 100
const monTierEl = document.getElementById('mon-tier') as HTMLSelectElement | null
if (monTierEl) monTierEl.value = this.monster.tier
const monDrainEl = document.getElementById('mon-drain-eff') as HTMLInputElement | null
if (monDrainEl) monDrainEl.value = String(this.monster.drainEffectiveness)
this.updateMonsterBadge()
}
private updateMonsterBadge() {
const badge = document.getElementById('monster-badge')!
const immunes: string[] = []
if (this.monster.resPhysical >= 100) immunes.push('物免')
if (this.monster.resFire >= 100) immunes.push('火免')
if (this.monster.resCold >= 100) immunes.push('冰免')
if (this.monster.resLightning >= 100) immunes.push('电免')
if (this.monster.resPoison >= 100) immunes.push('毒免')
if (this.monster.resMagic >= 100) immunes.push('魔免')
if (immunes.length > 0) {
badge.className = 'tag immune'
badge.textContent = immunes.join('·')
} else {
badge.className = 'tag res'
badge.textContent = this.monster.category
}
}
// --- Combat Action Triggers -----------------------------------------------
public executeMeleeAttack() {
if (this.player.hp <= 0) return
this.player.action = 'attack'
this.player.actionTimer = 15
let pMin = this.player.dmgMin
let pMax = this.player.dmgMax
if (this.player.flatDmg > 0 || this.player.offWeaponEd > 0 || this.player.statBonus > 0) {
const weaponCalc = calculatePhysicalWeaponDamage({
weaponBaseMin: pMin,
weaponBaseMax: pMax,
flatDamage: this.player.flatDmg,
offWeaponEdPercent: this.player.offWeaponEd,
statBonusPercent: this.player.statBonus,
})
pMin = weaponCalc.finalMinDamage
pMax = weaponCalc.finalMaxDamage
}
const result = calculateAttack(
this.player.level,
this.player.ar,
{
physicalMin: pMin,
physicalMax: pMax,
fire: this.player.fire,
cold: this.player.cold,
lightning: this.player.light,
poison: this.player.poison,
magic: this.player.magic,
},
{
level: this.monster.level,
defense: this.monster.dr,
blockPercent: this.monster.block,
resPhysical: this.monster.resPhysical,
resFire: this.monster.resFire,
resCold: this.monster.resCold,
resLightning: this.monster.resLightning,
resPoison: this.monster.resPoison,
resMagic: this.monster.resMagic,
flatDamageReduction: this.monster.flatDr,
flatMagicReduction: this.monster.flatMdr,
currentHp: this.monster.hp,
maxHp: this.monster.maxHp,
tier: this.monster.tier,
isRunning: this.monster.posture === 'run',
drainEffectiveness: this.monster.drainEffectiveness,
size: this.monster.size,
regenRate: this.monster.regenRate,
isUber: this.monster.isUber,
},
{
criticalStrikePercent: this.player.cs,
deadlyStrikePercent: this.player.ds,
crushingBlowPercent: this.player.cb,
openWoundsPercent: this.player.ow,
lifeLeechPercent: this.player.ll,
manaLeechPercent: this.player.ml,
ignoreTargetDefense: this.player.itd,
targetDefenseReductionPercent: this.player.defRed,
freezeBonus: this.player.freezeBonus,
knockbackPercent: this.player.knockback,
difficulty: this.player.difficulty,
isSpell: false,
isRanged: false,
// PvE Depth mechanics
convictionPenalty: this.player.conviction,
lowerResPenalty: this.player.lowerRes,
itemMinusFireRes: this.player.minusFireRes,
itemMinusLightRes: this.player.minusLightRes,
itemMinusColdRes: this.player.minusColdRes,
itemMinusPoisonRes: this.player.minusPoisonRes,
slowPercent: this.player.slow,
hitBlindsTargetBonus: this.player.hbt,
hitCausesFleePercent: this.player.hcmtf,
preventMonsterHeal: this.player.pmh,
attackerType: this.player.isMerc ? 'mercenary' : 'player',
},
)
this.applyCombatResult(result, 'melee')
}
public castFireSpell() {
if (this.player.hp <= 0) return
this.player.action = 'cast'
this.player.actionTimer = 16
const spellDmg = Math.round((this.player.fire > 0 ? this.player.fire * 1.5 : 45) + Math.random() * 20)
const pX = this.canvas.width * 0.35
const pY = this.canvas.height * 0.52
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
this.projectiles.push({
x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
type: 'fire', progress: 0, speed: 0.08,
onHit: () => {
const result = calculateAttack(
this.player.level, 0,
{ physicalMin: 0, physicalMax: 0, fire: spellDmg },
{
level: this.monster.level,
defense: this.monster.dr,
blockPercent: this.monster.block,
resFire: this.monster.resFire,
flatMagicReduction: this.monster.flatMdr,
currentHp: this.monster.hp,
maxHp: this.monster.maxHp,
tier: this.monster.tier,
isRunning: this.monster.posture === 'run',
size: this.monster.size,
isUber: this.monster.isUber,
},
{
isSpell: true,
difficulty: this.player.difficulty,
convictionPenalty: this.player.conviction,
lowerResPenalty: this.player.lowerRes,
itemMinusFireRes: this.player.minusFireRes,
},
)
this.applyCombatResult(result, 'fire')
this.spawnExplosion(mX, mY - 20, '#ff5722', 20)
},
})
}
public castColdSpell() {
if (this.player.hp <= 0) return
this.player.action = 'cast'
this.player.actionTimer = 16
const spellDmg = Math.round((this.player.cold > 0 ? this.player.cold * 1.6 : 38) + Math.random() * 15)
const pX = this.canvas.width * 0.35
const pY = this.canvas.height * 0.52
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
this.projectiles.push({
x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
type: 'cold', progress: 0, speed: 0.07,
onHit: () => {
const result = calculateAttack(
this.player.level, 0,
{ physicalMin: 0, physicalMax: 0, cold: spellDmg },
{
level: this.monster.level,
defense: this.monster.dr,
blockPercent: this.monster.block,
resCold: this.monster.resCold,
flatMagicReduction: this.monster.flatMdr,
currentHp: this.monster.hp,
maxHp: this.monster.maxHp,
tier: this.monster.tier,
isRunning: this.monster.posture === 'run',
size: this.monster.size,
isUber: this.monster.isUber,
},
{
isSpell: true,
difficulty: this.player.difficulty,
convictionPenalty: this.player.conviction,
lowerResPenalty: this.player.lowerRes,
itemMinusColdRes: this.player.minusColdRes,
},
)
this.applyCombatResult(result, 'cold')
if (result.finalColdDamage > 0) {
this.monster.chillTimer = 60
}
this.spawnExplosion(mX, mY - 20, '#29b6f6', 24)
},
})
}
public castLightningSpell() {
if (this.player.hp <= 0) return
this.player.action = 'cast'
this.player.actionTimer = 16
// Lightning has iconic wide damage range [1 .. High]
const spellDmg = Math.round(1 + Math.random() * (this.player.light > 0 ? this.player.light * 2.5 : 90))
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
const result = calculateAttack(
this.player.level, 0,
{ physicalMin: 0, physicalMax: 0, lightning: spellDmg },
{
level: this.monster.level,
defense: this.monster.dr,
blockPercent: this.monster.block,
resLightning: this.monster.resLightning,
flatMagicReduction: this.monster.flatMdr,
currentHp: this.monster.hp,
maxHp: this.monster.maxHp,
tier: this.monster.tier,
isRunning: this.monster.posture === 'run',
size: this.monster.size,
isUber: this.monster.isUber,
},
{
isSpell: true,
difficulty: this.player.difficulty,
convictionPenalty: this.player.conviction,
lowerResPenalty: this.player.lowerRes,
itemMinusLightRes: this.player.minusLightRes,
},
)
this.applyCombatResult(result, 'light')
this.spawnExplosion(mX, mY - 20, '#ffee58', 25)
}
public castMagicSpell() {
if (this.player.hp <= 0) return
this.player.action = 'cast'
this.player.actionTimer = 16
const spellDmg = Math.round((this.player.magic > 0 ? this.player.magic * 1.8 : 55) + Math.random() * 25)
const pX = this.canvas.width * 0.35
const pY = this.canvas.height * 0.52
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
this.projectiles.push({
x: pX, y: pY - 20, targetX: mX, targetY: mY - 20,
type: 'magic', progress: 0, speed: 0.09,
onHit: () => {
const result = calculateAttack(
this.player.level, 0,
{ physicalMin: 0, physicalMax: 0, magic: spellDmg },
{
level: this.monster.level,
defense: this.monster.dr,
blockPercent: this.monster.block,
resMagic: this.monster.resMagic,
flatMagicReduction: this.monster.flatMdr,
currentHp: this.monster.hp,
maxHp: this.monster.maxHp,
tier: this.monster.tier,
isRunning: this.monster.posture === 'run',
size: this.monster.size,
isUber: this.monster.isUber,
},
{ isSpell: true, difficulty: this.player.difficulty },
)
this.applyCombatResult(result, 'magic')
this.spawnExplosion(mX, mY - 20, '#ab47bc', 20)
},
})
}
public executeMonsterAttack() {
if (this.monster.hp <= 0 || this.monster.freezeTimer > 0 || this.monster.stunTimer > 0 || this.monster.fleeTimer > 0) return
this.monster.action = 'attack'
const slowFactor = 1 + (this.monster.slowPercent / 100)
this.monster.actionTimer = Math.round(18 * slowFactor)
// Monster strikes Player
const result = calculateAttack(
this.monster.level,
this.monster.ar,
{
physicalMin: this.monster.dmgMin,
physicalMax: this.monster.dmgMax,
},
{
level: this.player.level,
defense: this.player.dr,
blockPercent: this.player.block,
flatDamageReduction: this.player.flatDr,
flatMagicReduction: this.player.flatMdr,
currentHp: this.player.hp,
maxHp: this.player.maxHp,
tier: 'player',
},
{
difficulty: this.player.difficulty,
},
)
const pX = this.canvas.width * 0.35
const pY = this.canvas.height * 0.52
if (!result.isHit) {
this.addFloatingText(pX, pY - 40, 'MISS', '#90a4ae')
} else if (result.isBlocked) {
this.addFloatingText(pX, pY - 40, '🛡️ BLOCKED', '#64b5f6')
} else {
this.player.hp = Math.max(0, Math.round((this.player.hp - result.totalDamage) * 10) / 10)
this.player.hitFlash = 6
;(document.getElementById('char-hp') as HTMLInputElement).value = String(this.player.hp)
this.addFloatingText(pX, pY - 40, `-${result.totalDamage}`, '#e53935', 18)
if (this.player.hp <= 0) {
this.player.action = 'dead'
this.addFloatingText(pX, pY - 60, 'YOU DIED', '#d32f2f', 22)
}
}
this.appendLog(result.log, result.isHit ? (result.isBlocked ? 'blocked' : 'hit') : 'miss')
}
private applyCombatResult(result: CombatCalculationResult, kind: string) {
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
const pX = this.canvas.width * 0.35
const pY = this.canvas.height * 0.52
if (!result.isHit) {
this.addFloatingText(mX, mY - 40, 'MISS', '#90a4ae')
this.appendLog(result.log, 'miss')
return
}
if (result.isBlocked) {
this.addFloatingText(mX, mY - 40, '🛡️ BLOCKED', '#64b5f6')
this.appendLog(result.log, 'blocked')
return
}
// Apply Damage
this.monster.hp = Math.max(0, Math.round((this.monster.hp - result.totalDamage) * 10) / 10)
this.monster.hitFlash = 6
this.totalDamageDealt += result.totalDamage
;(document.getElementById('mon-hp') as HTMLInputElement).value = String(this.monster.hp)
// Damage floating text
let dmgColor = '#ffffff'
if (kind === 'fire' || result.finalFireDamage > 0) dmgColor = '#ff7043'
if (kind === 'cold' || result.finalColdDamage > 0) dmgColor = '#4fc3f7'
if (kind === 'light' || result.finalLightningDamage > 0) dmgColor = '#ffee58'
if (kind === 'magic' || result.finalMagicDamage > 0) dmgColor = '#ba68c8'
this.addFloatingText(mX, mY - 40, `-${result.totalDamage}`, dmgColor, result.isCritical ? 20 : 16)
// Critical & Deadly Strike distinction
if (result.isCritical) {
if (result.criticalTrigger === 'critical_strike') {
this.addFloatingText(mX, mY - 60, '⚡ CRITICAL STRIKE! (2x)', '#ffd54f', 14)
} else if (result.criticalTrigger === 'deadly_strike') {
this.addFloatingText(mX, mY - 60, '⚔️ DEADLY STRIKE! (2x)', '#ffb74d', 14)
} else {
this.addFloatingText(mX, mY - 60, '⚡ CRITICAL!', '#ffd54f', 14)
}
}
// Crushing Blow
if (result.isCrushingBlow) {
this.addFloatingText(mX, mY - 75, `💥 CRUSHING BLOW! (-${result.crushingBlowDamage})`, '#ef5350', 14)
this.spawnExplosion(mX, mY - 20, '#ff1744', 24)
}
// Open Wounds
if (result.isOpenWounds && result.openWoundsResult) {
this.monster.openWoundsTimer = 200
this.monster.openWoundsDmgPerFrame = result.openWoundsResult.damagePerFrame
this.addFloatingText(mX, mY - 90, '🩸 OPEN WOUNDS (8s)', '#d32f2f', 13)
this.spawnExplosion(mX, mY - 20, '#b71c1c', 16)
}
// Life & Mana Leech
if (result.lifeLeeched > 0) {
this.player.hp = Math.min(this.player.maxHp, this.player.hp + result.lifeLeeched)
;(document.getElementById('char-hp') as HTMLInputElement).value = String(Math.round(this.player.hp))
this.addFloatingText(pX, pY - 45, `+${result.lifeLeeched} HP`, '#4caf50', 13)
}
if (result.manaLeeched > 0) {
this.player.mana = Math.min(this.player.maxMana, this.player.mana + result.manaLeeched)
;(document.getElementById('char-mana') as HTMLInputElement).value = String(Math.round(this.player.mana))
this.addFloatingText(pX, pY - 60, `+${result.manaLeeched} MP`, '#42a5f5', 13)
}
// Freeze Target & Chilled
if (result.freezeResult?.willFreeze) {
this.monster.freezeTimer = result.freezeResult.durationFrames
this.addFloatingText(mX, mY - 105, `❄️ FROZEN (${result.freezeResult.durationSeconds}s)`, '#80deea', 14)
this.spawnExplosion(mX, mY - 20, '#80deea', 18)
} else if (result.freezeResult?.isChilledOnly) {
this.monster.chillTimer = result.freezeResult.durationFrames
this.addFloatingText(mX, mY - 105, `❄️ CHILLED (${result.freezeResult.durationSeconds}s)`, '#81d4fa', 13)
}
// Knockback
if (result.knockbackTriggered) {
this.monster.knockbackOffset = 25
this.addFloatingText(mX, mY - 30, '💨 KNOCKBACK', '#b0bec5', 12)
}
// Hit recovery
if (result.hitRecoveryTriggered) {
this.monster.stunTimer = 25
this.addFloatingText(mX, mY - 120, '💫 FHR STUN', '#ffca28', 12)
}
// Resistance Break Floating Text
if (result.resistanceBreakResults) {
for (const [elem, brk] of Object.entries(result.resistanceBreakResults)) {
if (brk.immunityBroken) {
const name = elem === 'physical' ? '物' : elem === 'fire' ? '火' : elem === 'lightning' ? '电' : elem === 'cold' ? '冰' : '毒'
this.addFloatingText(mX, mY - 135, `⚡ 破${name}免成功! (${brk.finalRes}%)`, '#ffd54f', 13)
}
}
}
// Prevent Monster Heal (PMH)
if (result.pmhResult) {
if (result.pmhResult.applied) {
this.monster.pmhTimer = result.pmhResult.durationFrames
this.addFloatingText(mX, mY - 150, '🚫 PMH 自愈阻断 (80分)', '#81c784', 13)
} else if (this.player.pmh) {
this.addFloatingText(mX, mY - 150, `🚫 PMH无效: ${result.pmhResult.reason}`, '#ef5350', 11)
}
}
// Slows Target
if (result.slowResult && result.slowResult.effectiveSlowPercent > 0) {
this.monster.slowTimer = 150
this.monster.slowPercent = result.slowResult.effectiveSlowPercent
this.addFloatingText(mX, mY - 120, `🐌 目标减速 -${result.slowResult.effectiveSlowPercent}%`, '#ffb74d', 13)
}
// Hit Blinds Target
if (result.blindResult?.triggered) {
this.monster.blindTimer = result.blindResult.durationFrames
this.addFloatingText(mX, mY - 165, `👁️ 目标致盲 (${result.blindResult.durationSeconds}s)`, '#ba68c8', 13)
this.spawnExplosion(mX, mY - 20, '#ab47bc', 14)
}
// Hit Causes Monster to Flee
if (result.fleeResult?.triggered) {
this.monster.fleeTimer = result.fleeResult.durationFrames
this.addFloatingText(mX, mY - 180, '😱 恐吓逃跑 (5s)', '#ff8a65', 13)
this.spawnExplosion(mX, mY - 20, '#ff7043', 14)
}
// Death Check
if (this.monster.hp <= 0) {
this.monster.action = 'dead'
this.addFloatingText(mX, mY - 60, '💀 SLAIN', '#e53935', 22)
const earnedXp = Math.round(50 * (this.monster.level / Math.max(1, this.player.level)))
this.addFloatingText(mX, mY - 80, `+${earnedXp} EXP`, '#81c784', 13)
}
this.appendLog(result.log, result.isCritical ? 'crit' : 'hit')
this.updateDpsMeter()
}
private toggleAutoBattle() {
this.autoBattleActive = !this.autoBattleActive
const btnLabel = document.getElementById('auto-battle-label')!
if (this.autoBattleActive) {
btnLabel.textContent = '⏸️ 停止对决'
this.combatStartTime = performance.now()
this.totalDamageDealt = 0
this.autoBattleInterval = window.setInterval(() => {
if (this.monster.hp <= 0 || this.player.hp <= 0) {
this.toggleAutoBattle()
return
}
this.executeMeleeAttack()
if (Math.random() < 0.6) {
this.executeMonsterAttack()
}
}, 400)
} else {
btnLabel.textContent = '🔄 开启自动对决'
if (this.autoBattleInterval) {
clearInterval(this.autoBattleInterval)
this.autoBattleInterval = null
}
}
}
public resetCombat() {
this.player.hp = this.player.maxHp
this.player.mana = this.player.maxMana
this.player.action = 'idle'
;(document.getElementById('char-hp') as HTMLInputElement).value = String(this.player.hp)
;(document.getElementById('char-mana') as HTMLInputElement).value = String(this.player.mana)
this.monster.hp = this.monster.maxHp
this.monster.action = 'idle'
this.monster.stunTimer = 0
this.monster.chillTimer = 0
this.monster.freezeTimer = 0
this.monster.openWoundsTimer = 0
this.monster.openWoundsDmgPerFrame = 0
this.monster.knockbackOffset = 0
this.monster.pmhTimer = 0
this.monster.blindTimer = 0
this.monster.fleeTimer = 0
this.monster.slowTimer = 0
this.monster.slowPercent = 0
;(document.getElementById('mon-hp') as HTMLInputElement).value = String(this.monster.hp)
this.floatingTexts = []
this.projectiles = []
this.totalDamageDealt = 0
this.combatStartTime = 0
this.updateDpsMeter()
this.appendLog(['[重置]: 玩家与怪物恢复满生命值,所有受击硬直、流血、冻结与击退状态已清空。'], 'hit')
}
private updateDpsMeter() {
const dpsEl = document.getElementById('stat-dps')!
if (this.combatStartTime > 0) {
const elapsed = (performance.now() - this.combatStartTime) / 1000
if (elapsed > 0.5) {
const dps = this.totalDamageDealt / elapsed
dpsEl.textContent = `DPS: ${dps.toFixed(1)} (累计: ${this.totalDamageDealt.toFixed(0)})`
return
}
}
dpsEl.textContent = `DPS: 0.0 (累计: ${this.totalDamageDealt.toFixed(0)})`
}
private appendLog(lines: readonly string[], style: 'hit' | 'miss' | 'blocked' | 'crit') {
const container = document.getElementById('log-entries')!
const item = document.createElement('div')
item.className = `log-item ${style}`
item.innerHTML = lines.map(line => `<div>${escapeHtml(line)}</div>`).join('')
container.insertBefore(item, container.firstChild)
// Keep max 80 entries
while (container.children.length > 80) {
container.removeChild(container.lastChild!)
}
}
private addFloatingText(x: number, y: number, text: string, color: string, size = 15) {
this.floatingTexts.push({
x: x + (Math.random() - 0.5) * 30,
y: y + (Math.random() - 0.5) * 15,
text, color, size, alpha: 1.0, vy: -1.2, life: 50,
})
}
private spawnExplosion(x: number, y: number, color: string, count = 16) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2
const speed = 1.5 + Math.random() * 3.5
this.particles.push({
x, y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
color,
size: 2 + Math.random() * 3,
life: 25,
maxLife: 25,
})
}
}
// --- Render Loop ----------------------------------------------------------
private startRenderLoop() {
const loop = () => {
this.update()
this.render()
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
}
private update() {
// Player timers
if (this.player.actionTimer > 0) {
this.player.actionTimer -= 1
if (this.player.actionTimer === 0 && this.player.action !== 'dead') {
this.player.action = 'idle'
}
}
if (this.player.hitFlash > 0) this.player.hitFlash -= 1
// Monster timers
if (this.monster.actionTimer > 0) {
this.monster.actionTimer -= 1
if (this.monster.actionTimer === 0 && this.monster.action !== 'dead') {
this.monster.action = 'idle'
}
}
if (this.monster.hitFlash > 0) this.monster.hitFlash -= 1
if (this.monster.stunTimer > 0) this.monster.stunTimer -= 1
if (this.monster.chillTimer > 0) this.monster.chillTimer -= 1
if (this.monster.freezeTimer > 0) this.monster.freezeTimer -= 1
if (this.monster.knockbackOffset > 0) {
this.monster.knockbackOffset = Math.max(0, this.monster.knockbackOffset - 1.5)
}
if (this.monster.pmhTimer > 0) this.monster.pmhTimer -= 1
if (this.monster.blindTimer > 0) this.monster.blindTimer -= 1
if (this.monster.fleeTimer > 0) this.monster.fleeTimer -= 1
if (this.monster.slowTimer > 0) {
this.monster.slowTimer -= 1
if (this.monster.slowTimer === 0) this.monster.slowPercent = 0
}
// Natural Monster Regeneration (blocked by OW or PMH)
if (this.monster.hp > 0 && this.monster.hp < this.monster.maxHp && this.monster.regenRate > 0) {
const regen = calculateMonsterRegeneration(
this.monster.maxHp,
this.monster.regenRate,
this.monster.openWoundsTimer > 0,
this.monster.pmhTimer > 0,
)
if (regen.effectiveRegenPerFrame > 0) {
this.monster.hp = Math.min(this.monster.maxHp, Math.round((this.monster.hp + regen.effectiveRegenPerFrame) * 100) / 100)
const hpInput = document.getElementById('mon-hp') as HTMLInputElement | null
if (hpInput) hpInput.value = String(Math.round(this.monster.hp))
}
}
// Open Wounds bleeding tick
if (this.monster.openWoundsTimer > 0 && this.monster.hp > 0) {
this.monster.openWoundsTimer -= 1
const tickDmg = this.monster.openWoundsDmgPerFrame
this.monster.hp = Math.max(0, Math.round((this.monster.hp - tickDmg) * 10) / 10)
const hpInput = document.getElementById('mon-hp') as HTMLInputElement | null
if (hpInput) hpInput.value = String(Math.round(this.monster.hp))
const mX = this.canvas.width * 0.65
const mY = this.canvas.height * 0.52
// Visual tick every 25 frames (1 second in 25 fps game clock)
if (this.monster.openWoundsTimer % 25 === 0) {
const secDmg = Math.round(tickDmg * 25 * 10) / 10
this.addFloatingText(mX + (Math.random() - 0.5) * 20, mY - 35, `-${secDmg} 🩸`, '#d32f2f', 12)
this.spawnExplosion(mX, mY - 20, '#b71c1c', 6)
}
if (this.monster.hp <= 0 && this.monster.action !== 'dead') {
this.monster.action = 'dead'
this.addFloatingText(mX, mY - 60, '💀 BLED TO DEATH', '#b71c1c', 22)
}
}
// Update Floating Text
for (let i = this.floatingTexts.length - 1; i >= 0; i--) {
const ft = this.floatingTexts[i]!
ft.y += ft.vy
ft.life -= 1
ft.alpha = Math.max(0, ft.life / 50)
if (ft.life <= 0) this.floatingTexts.splice(i, 1)
}
// Update Projectiles
for (let i = this.projectiles.length - 1; i >= 0; i--) {
const p = this.projectiles[i]!
p.progress += p.speed
p.x = p.x + (p.targetX - p.x) * p.speed * 2
p.y = p.y + (p.targetY - p.y) * p.speed * 2
if (p.progress >= 1 || Math.hypot(p.targetX - p.x, p.targetY - p.y) < 15) {
p.onHit()
this.projectiles.splice(i, 1)
}
}
// Update Particles
for (let i = this.particles.length - 1; i >= 0; i--) {
const pt = this.particles[i]!
pt.x += pt.vx
pt.y += pt.vy
pt.life -= 1
if (pt.life <= 0) this.particles.splice(i, 1)
}
}
private render() {
const ctx = this.ctx
const w = this.canvas.width
const h = this.canvas.height
// Background
ctx.fillStyle = '#0a0806'
ctx.fillRect(0, 0, w, h)
// Draw Isometric Duel Arena
this.renderArenaGround(ctx, w, h)
// Positions
const pX = w * 0.35
const pY = h * 0.52
const mX = w * 0.65
const mY = h * 0.52
// Shadows
this.renderShadow(ctx, pX, pY + 8, 28, 12)
this.renderShadow(ctx, mX + this.monster.knockbackOffset, mY + 8, 32, 14)
// Render Characters
this.renderPlayerAvatar(ctx, pX, pY)
this.renderMonsterAvatar(ctx, mX, mY)
// Render Projectiles & Particles
this.renderProjectiles(ctx)
this.renderParticles(ctx)
// Overhead Status Bars
this.renderOverheadPlayerBar(ctx, pX, pY - 80)
this.renderOverheadMonsterBar(ctx, mX + this.monster.knockbackOffset, mY - 85)
// Floating combat text
this.renderFloatingTexts(ctx)
}
private renderArenaGround(ctx: CanvasRenderingContext2D, w: number, h: number) {
const cX = w * 0.5
const cY = h * 0.52
// Large outer rune diamond
ctx.save()
ctx.beginPath()
ctx.moveTo(cX, cY - 140)
ctx.lineTo(cX + 280, cY)
ctx.lineTo(cX, cY + 140)
ctx.lineTo(cX - 280, cY)
ctx.closePath()
ctx.fillStyle = '#14100c'
ctx.fill()
ctx.lineWidth = 3
ctx.strokeStyle = '#3d2f20'
ctx.stroke()
// Inner patterned ring
ctx.beginPath()
ctx.ellipse(cX, cY, 200, 95, 0, 0, Math.PI * 2)
ctx.fillStyle = '#1a140f'
ctx.fill()
ctx.lineWidth = 1.5
ctx.strokeStyle = '#5a432b'
ctx.stroke()
// Runic circle lines
ctx.beginPath()
ctx.ellipse(cX, cY, 130, 60, 0, 0, Math.PI * 2)
ctx.lineWidth = 1
ctx.strokeStyle = '#8c693a'
ctx.stroke()
// Isometric grid lines inside arena
ctx.strokeStyle = '#271f16'
ctx.lineWidth = 1
for (let x = -4; x <= 4; x++) {
ctx.beginPath()
ctx.moveTo(cX + x * 40, cY - 70 + Math.abs(x) * 15)
ctx.lineTo(cX + x * 40, cY + 70 - Math.abs(x) * 15)
ctx.stroke()
}
// Torch poles on corners
this.renderTorch(ctx, cX - 250, cY)
this.renderTorch(ctx, cX + 250, cY)
this.renderTorch(ctx, cX, cY - 120)
this.renderTorch(ctx, cX, cY + 120)
ctx.restore()
}
private renderTorch(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.fillStyle = '#3a2b1c'
ctx.fillRect(x - 3, y - 25, 6, 25)
ctx.fillStyle = '#1c150e'
ctx.fillRect(x - 6, y - 28, 12, 4)
// Torch Fire Animation
const flick = Math.sin(Date.now() * 0.01 + x) * 2
ctx.beginPath()
ctx.arc(x, y - 32 + flick, 6, 0, Math.PI * 2)
ctx.fillStyle = '#ff9800'
ctx.fill()
ctx.beginPath()
ctx.arc(x, y - 34 + flick, 3, 0, Math.PI * 2)
ctx.fillStyle = '#ffeb3b'
ctx.fill()
}
private renderShadow(ctx: CanvasRenderingContext2D, x: number, y: number, rx: number, ry: number) {
ctx.save()
ctx.beginPath()
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)'
ctx.fill()
ctx.restore()
}
private renderPlayerAvatar(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.save()
// Hit flash overlay
if (this.player.hitFlash > 0) {
ctx.filter = 'brightness(1.8) drop-shadow(0 0 8px rgba(255,50,50,0.8))'
}
const isAttacking = this.player.action === 'attack'
const isCasting = this.player.action === 'cast'
const isDead = this.player.action === 'dead'
const breath = Math.sin(Date.now() * 0.004) * 2
if (isDead) {
ctx.translate(x, y)
ctx.rotate(-Math.PI / 2)
ctx.fillStyle = '#555'
ctx.fillRect(-15, -40, 30, 40)
ctx.restore()
return
}
// Body base
ctx.translate(x, y + breath)
// Cloak / Robes
ctx.fillStyle = this.player.class === 'Sorceress' ? '#283593' : '#37474f'
ctx.beginPath()
ctx.moveTo(-16, 0)
ctx.lineTo(16, 0)
ctx.lineTo(10, -40)
ctx.lineTo(-10, -40)
ctx.closePath()
ctx.fill()
// Torso / Armour
ctx.fillStyle = '#d4af37'
ctx.fillRect(-9, -42, 18, 22)
// Belt
ctx.fillStyle = '#4e342e'
ctx.fillRect(-9, -23, 18, 4)
// Head
ctx.fillStyle = '#ffcc80'
ctx.beginPath()
ctx.arc(0, -48, 8, 0, Math.PI * 2)
ctx.fill()
// Hair / Hood
ctx.fillStyle = this.player.class === 'Sorceress' ? '#bf360c' : '#212121'
ctx.beginPath()
ctx.arc(0, -50, 8.5, Math.PI * 0.8, Math.PI * 2.2)
ctx.fill()
// Weapon / Staff
ctx.save()
if (isAttacking) {
ctx.rotate(0.35)
ctx.fillStyle = '#b0bec5'
ctx.fillRect(10, -50, 4, 45) // Sword slash
ctx.fillStyle = '#ffd54f'
ctx.fillRect(6, -25, 12, 4)
} else if (isCasting) {
ctx.fillStyle = '#81d4fa'
ctx.fillRect(8, -65, 3, 55) // Staff raised
ctx.beginPath()
ctx.arc(9.5, -67, 7, 0, Math.PI * 2)
ctx.fillStyle = 'rgba(129, 212, 250, 0.6)'
ctx.fill()
} else {
ctx.fillStyle = '#b0bec5'
ctx.fillRect(8, -40, 3, 40)
}
ctx.restore()
// Shield (if has block)
if (this.player.block > 0) {
ctx.fillStyle = '#cfd8dc'
ctx.beginPath()
ctx.moveTo(-18, -35)
ctx.lineTo(-6, -35)
ctx.lineTo(-8, -18)
ctx.lineTo(-12, -12)
ctx.lineTo(-16, -18)
ctx.closePath()
ctx.fill()
ctx.strokeStyle = '#d4af37'
ctx.lineWidth = 1.5
ctx.stroke()
}
ctx.restore()
}
private renderMonsterAvatar(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.save()
const drawX = x + this.monster.knockbackOffset
if (this.monster.hitFlash > 0) {
ctx.filter = 'brightness(2.0) drop-shadow(0 0 10px rgba(255,30,30,0.9))'
} else if (this.monster.freezeTimer > 0) {
ctx.filter = 'drop-shadow(0 0 12px rgba(0,229,255,0.9)) hue-rotate(190deg) brightness(1.2)'
} else if (this.monster.chillTimer > 0 || this.monster.slowTimer > 0) {
ctx.filter = 'drop-shadow(0 0 8px rgba(33,150,243,0.8)) hue-rotate(180deg)'
} else if (this.monster.blindTimer > 0) {
ctx.filter = 'drop-shadow(0 0 8px rgba(171,71,188,0.8)) brightness(0.6)'
} else if (this.monster.fleeTimer > 0) {
ctx.filter = 'drop-shadow(0 0 8px rgba(255,112,67,0.8))'
} else if (this.monster.openWoundsTimer > 0) {
ctx.filter = 'drop-shadow(0 0 8px rgba(183,28,28,0.8))'
}
const isAttacking = this.monster.action === 'attack'
const isDead = this.monster.action === 'dead'
const isStunned = this.monster.stunTimer > 0
const breath = Math.sin(Date.now() * 0.005) * 2
if (isDead) {
ctx.translate(drawX, y)
ctx.rotate(Math.PI / 2)
ctx.fillStyle = '#424242'
ctx.fillRect(-15, -40, 30, 40)
ctx.restore()
return
}
ctx.translate(drawX, y + (isStunned ? -4 : breath))
if (this.monster.fleeTimer > 0) {
ctx.scale(-1, 1) // Turn around in terror
}
// Monster Base Shape according to template
const color = this.monster.color
ctx.fillStyle = color
if (this.monster.typeKey.startsWith('fallen')) {
// Small hunched demon
ctx.beginPath()
ctx.ellipse(0, -22, 14, 18, -0.2, 0, Math.PI * 2)
ctx.fill()
// Horns
ctx.fillStyle = '#212121'
ctx.beginPath()
ctx.moveTo(-8, -36); ctx.lineTo(-12, -44); ctx.lineTo(-4, -38)
ctx.moveTo(4, -38); ctx.lineTo(8, -44); ctx.lineTo(6, -36)
ctx.fill()
// Small dagger
ctx.fillStyle = '#90a4ae'
ctx.fillRect(isAttacking ? -22 : -14, -26, 12, 3)
} else if (this.monster.typeKey.startsWith('zombie')) {
// Tall shambling corpse
ctx.fillRect(-12, -48, 24, 45)
ctx.fillStyle = '#33691e'
ctx.beginPath()
ctx.arc(0, -52, 10, 0, Math.PI * 2)
ctx.fill()
} else if (this.monster.typeKey === 'diablo' || this.monster.typeKey === 'baal') {
// Huge boss figure
ctx.beginPath()
ctx.ellipse(0, -45, 26, 42, 0, 0, Math.PI * 2)
ctx.fill()
// Giant Horns
ctx.fillStyle = '#212121'
ctx.beginPath()
ctx.moveTo(-16, -75); ctx.lineTo(-32, -95); ctx.lineTo(-12, -80)
ctx.moveTo(12, -80); ctx.lineTo(32, -95); ctx.lineTo(16, -75)
ctx.fill()
// Glowing Eyes
ctx.fillStyle = '#ffeb3b'
ctx.beginPath()
ctx.arc(-8, -70, 3, 0, Math.PI * 2)
ctx.arc(8, -70, 3, 0, Math.PI * 2)
ctx.fill()
} else if (this.monster.typeKey === 'dummy') {
// Wood target dummy
ctx.fillStyle = '#8d6e63'
ctx.fillRect(-6, -45, 12, 45)
ctx.fillRect(-22, -38, 44, 10)
ctx.beginPath()
ctx.arc(0, -45, 12, 0, Math.PI * 2)
ctx.fillStyle = '#d7ccc8'
ctx.fill()
ctx.strokeStyle = '#c62828'
ctx.lineWidth = 2
ctx.stroke()
} else {
// Generic humanoid monster
ctx.fillRect(-12, -42, 24, 40)
ctx.beginPath()
ctx.arc(0, -46, 9, 0, Math.PI * 2)
ctx.fill()
}
// Frozen Ice Crystal Overlay
if (this.monster.freezeTimer > 0 && !isDead) {
ctx.save()
ctx.fillStyle = 'rgba(128, 222, 234, 0.45)'
ctx.strokeStyle = '#80deea'
ctx.lineWidth = 2
ctx.beginPath()
ctx.rect(-24, -68, 48, 72)
ctx.fill()
ctx.stroke()
// Ice crack lines
ctx.beginPath()
ctx.moveTo(-14, -50); ctx.lineTo(-2, -36); ctx.lineTo(14, -46)
ctx.moveTo(-2, -36); ctx.lineTo(-6, -18)
ctx.moveTo(3, -28); ctx.lineTo(12, -14)
ctx.strokeStyle = '#e0f7fa'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.restore()
}
// Open Wounds Bleeding Aura
if (this.monster.openWoundsTimer > 0 && !isDead) {
ctx.save()
ctx.fillStyle = '#d32f2f'
const t = Date.now() * 0.008
for (let i = 0; i < 4; i++) {
const dropY = ((t * 40 + i * 14) % 45) - 38
const dropX = (i % 2 === 0 ? 1 : -1) * (8 + i * 3)
ctx.beginPath()
ctx.arc(dropX, dropY, 2, 0, Math.PI * 2)
ctx.fill()
}
ctx.restore()
}
ctx.restore()
}
private renderProjectiles(ctx: CanvasRenderingContext2D) {
ctx.save()
for (const p of this.projectiles) {
ctx.beginPath()
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
if (p.type === 'fire') {
ctx.fillStyle = '#ff5722'
ctx.shadowColor = '#ff9800'
ctx.shadowBlur = 12
} else if (p.type === 'cold') {
ctx.fillStyle = '#00e5ff'
ctx.shadowColor = '#80d8ff'
ctx.shadowBlur = 10
} else if (p.type === 'light') {
ctx.fillStyle = '#ffff00'
ctx.shadowColor = '#ffeb3b'
ctx.shadowBlur = 12
} else {
ctx.fillStyle = '#e040fb'
ctx.shadowColor = '#ea80fc'
ctx.shadowBlur = 12
}
ctx.fill()
}
ctx.restore()
}
private renderParticles(ctx: CanvasRenderingContext2D) {
ctx.save()
for (const pt of this.particles) {
ctx.beginPath()
ctx.arc(pt.x, pt.y, pt.size, 0, Math.PI * 2)
ctx.fillStyle = pt.color
ctx.globalAlpha = Math.max(0, pt.life / pt.maxLife)
ctx.fill()
}
ctx.restore()
}
private renderOverheadPlayerBar(ctx: CanvasRenderingContext2D, x: number, y: number) {
const barW = 80
const barH = 7
// HP Bar
ctx.fillStyle = '#1c150e'
ctx.fillRect(x - barW / 2, y, barW, barH)
const hpRatio = Math.max(0, Math.min(1, this.player.hp / this.player.maxHp))
ctx.fillStyle = '#d32f2f'
ctx.fillRect(x - barW / 2, y, barW * hpRatio, barH)
ctx.strokeStyle = '#4e342e'
ctx.lineWidth = 1
ctx.strokeRect(x - barW / 2, y, barW, barH)
// Mana Bar
ctx.fillStyle = '#1c150e'
ctx.fillRect(x - barW / 2, y + 8, barW, 4)
const manaRatio = Math.max(0, Math.min(1, this.player.mana / this.player.maxMana))
ctx.fillStyle = '#1976d2'
ctx.fillRect(x - barW / 2, y + 8, barW * manaRatio, 4)
// Text Label
ctx.font = '10px ui-monospace, monospace'
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.fillText(`${this.player.class} (Lv${this.player.level})`, x, y - 5)
ctx.fillStyle = '#ffcdd2'
ctx.fillText(`${Math.round(this.player.hp)}/${this.player.maxHp}`, x, y + barH + 16)
}
private renderOverheadMonsterBar(ctx: CanvasRenderingContext2D, x: number, y: number) {
const barW = 100
const barH = 8
// Status Badges (PMH, Slow, Blind, Flee, Frozen, Chill, OW)
const badges: { text: string; color: string }[] = []
if (this.monster.pmhTimer > 0) badges.push({ text: '🚫PMH', color: '#81c784' })
if (this.monster.slowTimer > 0) badges.push({ text: `🐌-${this.monster.slowPercent}%`, color: '#ffb74d' })
if (this.monster.blindTimer > 0) badges.push({ text: '👁️BLIND', color: '#ba68c8' })
if (this.monster.fleeTimer > 0) badges.push({ text: '😱FLEE', color: '#ff8a65' })
if (this.monster.freezeTimer > 0) badges.push({ text: '❄️FROZEN', color: '#80deea' })
else if (this.monster.chillTimer > 0) badges.push({ text: '❄️CHILL', color: '#81d4fa' })
if (this.monster.openWoundsTimer > 0) badges.push({ text: '🩸OW', color: '#e53935' })
if (badges.length > 0) {
ctx.font = 'bold 9px ui-monospace, monospace'
let badgeX = x - (badges.length * 44) / 2 + 22
for (const b of badges) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'
ctx.fillRect(badgeX - 20, y - 22, 40, 13)
ctx.fillStyle = b.color
ctx.textAlign = 'center'
ctx.fillText(b.text, badgeX, y - 12)
badgeX += 44
}
}
// HP Bar
ctx.fillStyle = '#1c150e'
ctx.fillRect(x - barW / 2, y, barW, barH)
const hpRatio = Math.max(0, Math.min(1, this.monster.hp / this.monster.maxHp))
ctx.fillStyle = '#c62828'
ctx.fillRect(x - barW / 2, y, barW * hpRatio, barH)
ctx.strokeStyle = '#5d4037'
ctx.lineWidth = 1
ctx.strokeRect(x - barW / 2, y, barW, barH)
// Text Label
ctx.font = '11px ui-monospace, monospace'
ctx.fillStyle = '#f0c070'
ctx.textAlign = 'center'
ctx.fillText(`${this.monster.name} Lv${this.monster.level}`, x, y - 5)
ctx.font = '10px ui-monospace, monospace'
ctx.fillStyle = '#ffcdd2'
ctx.fillText(`${Math.round(this.monster.hp)}/${this.monster.maxHp}`, x, y + barH + 12)
}
private renderFloatingTexts(ctx: CanvasRenderingContext2D) {
ctx.save()
ctx.textAlign = 'center'
for (const ft of this.floatingTexts) {
ctx.font = `bold ${ft.size}px ui-monospace, monospace`
ctx.globalAlpha = ft.alpha
ctx.strokeStyle = '#000'
ctx.lineWidth = 3
ctx.strokeText(ft.text, ft.x, ft.y)
ctx.fillStyle = ft.color
ctx.fillText(ft.text, ft.x, ft.y)
}
ctx.restore()
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
// Start application when DOM is ready
window.addEventListener('DOMContentLoaded', () => {
new CombatArenaApp()
})