/** * 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 = { 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 => `
${escapeHtml(line)}
`).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, '&').replace(//g, '>') } // Start application when DOM is ready window.addEventListener('DOMContentLoaded', () => { new CombatArenaApp() })