1843 lines
72 KiB
TypeScript
1843 lines
72 KiB
TypeScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|
||
|
||
// Start application when DOM is ready
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
new CombatArenaApp()
|
||
})
|