attack animations
This commit is contained in:
+331
-121
@@ -94,6 +94,7 @@ const MAP_BLOCKS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 }
|
const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 }
|
||||||
|
const TURN_SPEED = 6
|
||||||
|
|
||||||
const bodyPartLabels = {
|
const bodyPartLabels = {
|
||||||
head: 'Head', torso: 'Torso',
|
head: 'Head', torso: 'Torso',
|
||||||
@@ -166,11 +167,13 @@ function computeMove(from, to, speed) {
|
|||||||
if (dist <= speed) return { x: to.x, y: to.y }
|
if (dist <= speed) return { x: to.x, y: to.y }
|
||||||
|
|
||||||
const step = 0.5
|
const step = 0.5
|
||||||
const steps = Math.floor(speed / step)
|
const totalSteps = Math.ceil(speed / step)
|
||||||
let cx = from.x, cy = from.y
|
let cx = from.x, cy = from.y
|
||||||
for (let i = 0; i < steps; i++) {
|
for (let i = 0; i < totalSteps; i++) {
|
||||||
const nx = cx + (dx / dist) * step
|
const remaining = speed - i * step
|
||||||
const ny = cy + (dy / dist) * step
|
const thisStep = Math.min(step, remaining)
|
||||||
|
const nx = cx + (dx / dist) * thisStep
|
||||||
|
const ny = cy + (dy / dist) * thisStep
|
||||||
if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break
|
if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break
|
||||||
cx = nx; cy = ny
|
cx = nx; cy = ny
|
||||||
}
|
}
|
||||||
@@ -276,7 +279,7 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp
|
|||||||
* Run enemy AI. Returns an array of raw action objects.
|
* Run enemy AI. Returns an array of raw action objects.
|
||||||
* Uses the same computeMove / computeHit as the player.
|
* Uses the same computeMove / computeHit as the player.
|
||||||
*/
|
*/
|
||||||
function runEnemyAI(combat, playerParts, playerInjuries) {
|
function runEnemyAI(combat, playerParts, playerInjuries, enemyDirs) {
|
||||||
const actions = []
|
const actions = []
|
||||||
const moveTargets = {}
|
const moveTargets = {}
|
||||||
|
|
||||||
@@ -316,6 +319,12 @@ function runEnemyAI(combat, playerParts, playerInjuries) {
|
|||||||
const newPos = computeMove(e.pos, combat.playerPos, speed)
|
const newPos = computeMove(e.pos, combat.playerPos, speed)
|
||||||
if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) {
|
if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) {
|
||||||
moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y }
|
moveTargets[i] = { fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y }
|
||||||
|
if (enemyDirs) {
|
||||||
|
const dx = newPos.x - e.pos.x
|
||||||
|
const dy = newPos.y - e.pos.y
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (d > 0) enemyDirs[i] = { dx: dx / d, dy: dy / d }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,7 +419,7 @@ export default function App() {
|
|||||||
const [moveMode, setMoveMode] = useState(null)
|
const [moveMode, setMoveMode] = useState(null)
|
||||||
|
|
||||||
// ── animation state
|
// ── animation state
|
||||||
const [attackLerp, setAttackLerp] = useState({ player: null, enemies: {} })
|
const [animations, setAnimations] = useState([])
|
||||||
const [hitShake, setHitShake] = useState(null)
|
const [hitShake, setHitShake] = useState(null)
|
||||||
const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 })
|
const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 })
|
||||||
|
|
||||||
@@ -447,6 +456,10 @@ export default function App() {
|
|||||||
const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons
|
const equippedWeaponsRef = useRef(null); equippedWeaponsRef.current = equippedWeapons
|
||||||
// Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef)
|
// Tracks the latest enemy positions so AI reads fresh positions (not stale combatRef)
|
||||||
const positionsRef = useRef({})
|
const positionsRef = useRef({})
|
||||||
|
const playerDirRef = useRef({ dx: 0, dy: 1 })
|
||||||
|
const playerTargetDirRef = useRef({ dx: 0, dy: 1 })
|
||||||
|
const enemyDirsRef = useRef({})
|
||||||
|
const wasBusyRef = useRef(false)
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// DERIVED
|
// DERIVED
|
||||||
@@ -469,31 +482,53 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// TIMER — only ticks while timerActive is true
|
// ANIMATION SYSTEM — modular & extensible
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// To add a new animation type:
|
||||||
|
// 1. Add an entry to ANIM_TYPES
|
||||||
|
// 2. Add a config to ANIM_CFG { duration, hitAt }
|
||||||
|
// 3. Add a factory function that creates the animation object
|
||||||
|
// 4. Add a case to getAnimPos() for rendering
|
||||||
|
// 5. Call the factory when creating the animation
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LUNGE_DIST = 18
|
const ANIM_TYPES = {
|
||||||
|
SLASH: 'slash',
|
||||||
|
PIERCE_STRIKE: 'pierce_strike',
|
||||||
|
}
|
||||||
|
|
||||||
function makeAttackAnim(action, cc) {
|
const ANIM_CFG = {
|
||||||
|
[ANIM_TYPES.SLASH]: { duration: 0.3, hitAt: 0.15 },
|
||||||
|
[ANIM_TYPES.PIERCE_STRIKE]: { duration: 0.3, hitAt: 0.15 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory: create a weapon animation based on the skill's damage type.
|
||||||
|
* slash → arcing motion, pierce → straight thrust, blunt → straight strike.
|
||||||
|
*/
|
||||||
|
function createWeaponStrikeAnim(action, cc) {
|
||||||
const isPlayer = action.actor === 'player'
|
const isPlayer = action.actor === 'player'
|
||||||
const startPos = isPlayer
|
const startPos = isPlayer
|
||||||
? { ...(cc?.playerPos ?? { x: 20, y: 75 }) }
|
? { ...(cc?.playerPos ?? { x: 20, y: 75 }) }
|
||||||
: { ...(cc?.enemies[action.enemyIndex]?.pos ?? { x: 20, y: 75 }) }
|
: { ...(cc?.enemies[action.enemyIndex]?.pos ?? { x: 20, y: 75 }) }
|
||||||
const targetPos = isPlayer
|
const endPos = isPlayer
|
||||||
? { ...(cc?.enemies[action.enemyIndex]?.pos ?? startPos) }
|
? { ...(cc?.enemies[action.enemyIndex]?.pos ?? startPos) }
|
||||||
: { ...(cc?.playerPos ?? startPos) }
|
: { ...(cc?.playerPos ?? startPos) }
|
||||||
const dx = targetPos.x - startPos.x
|
|
||||||
const dy = targetPos.y - startPos.y
|
const weapon = weaponMap[action.weaponId]
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
const skill = weapon?.skills?.find(s => s.name === action.skillName)
|
||||||
const lungeDist = Math.min(LUNGE_DIST, dist)
|
const damageType = skill?.damageType ?? 'blunt'
|
||||||
const lungeTarget = {
|
const type = damageType === 'slash' ? ANIM_TYPES.SLASH : ANIM_TYPES.PIERCE_STRIKE
|
||||||
x: startPos.x + (dx / dist) * lungeDist,
|
|
||||||
y: startPos.y + (dy / dist) * lungeDist,
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
type: 'attack',
|
id: `${action.actor}-${action.enemyIndex}-${Date.now()}`,
|
||||||
|
type,
|
||||||
actor: action.actor,
|
actor: action.actor,
|
||||||
enemyIndex: action.enemyIndex,
|
enemyIndex: action.enemyIndex,
|
||||||
|
startPos,
|
||||||
|
endPos,
|
||||||
|
elapsed: 0,
|
||||||
|
hitFired: false,
|
||||||
targetPart: action.targetPart,
|
targetPart: action.targetPart,
|
||||||
weaponId: action.weaponId,
|
weaponId: action.weaponId,
|
||||||
skillName: action.skillName,
|
skillName: action.skillName,
|
||||||
@@ -505,38 +540,74 @@ export default function App() {
|
|||||||
attackerInjuries: action.attackerInjuries,
|
attackerInjuries: action.attackerInjuries,
|
||||||
targetParts: action.targetParts,
|
targetParts: action.targetParts,
|
||||||
targetInjuries: action.targetInjuries,
|
targetInjuries: action.targetInjuries,
|
||||||
startPos, lungeTarget,
|
|
||||||
elapsed: 0,
|
|
||||||
hitFired: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance an attack animation by dt seconds. Returns true when done.
|
* Slash: arc toward the target (perpendicular offset).
|
||||||
*/
|
*/
|
||||||
function tickAttack(anim, dt) {
|
function getSlashPos(anim) {
|
||||||
const liveCc = combatRef.current
|
const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.SLASH]
|
||||||
if (anim.actor === 'player' && isDead(bodyPartsRef.current)) return true
|
|
||||||
if (anim.actor === 'enemy') {
|
|
||||||
const e = liveCc?.enemies[anim.enemyIndex]
|
|
||||||
if (!e || isDead(e.integrity)) { enemyActionsRef.current[anim.enemyIndex] = null; return true }
|
|
||||||
}
|
|
||||||
|
|
||||||
anim.elapsed += dt
|
|
||||||
const hitAt = 0.15
|
|
||||||
const returnAt = 0.30
|
|
||||||
|
|
||||||
const t = anim.elapsed < hitAt
|
const t = anim.elapsed < hitAt
|
||||||
? anim.elapsed / hitAt
|
? anim.elapsed / hitAt
|
||||||
: anim.elapsed < returnAt
|
: anim.elapsed < duration
|
||||||
? 1 - (anim.elapsed - hitAt) / (returnAt - hitAt)
|
? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
|
||||||
: 0
|
: 0
|
||||||
setAttackLerp(prev => anim.actor === 'player'
|
const dx = anim.endPos.x - anim.startPos.x
|
||||||
? { ...prev, player: { from: anim.startPos, to: anim.lungeTarget, t } }
|
const dy = anim.endPos.y - anim.startPos.y
|
||||||
: { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: { from: anim.startPos, to: anim.lungeTarget, t } } })
|
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||||
|
const nx = -dy / dist
|
||||||
|
const ny = dx / dist
|
||||||
|
const arcHeight = Math.sin(t * Math.PI) * 8
|
||||||
|
return {
|
||||||
|
x: anim.startPos.x + dx * t + nx * arcHeight,
|
||||||
|
y: anim.startPos.y + dy * t + ny * arcHeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (anim.elapsed >= hitAt && !anim.hitFired) {
|
/**
|
||||||
anim.hitFired = true
|
* Pierce / blunt: straight thrust toward the target.
|
||||||
|
*/
|
||||||
|
function getPierceStrikePos(anim) {
|
||||||
|
const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.PIERCE_STRIKE]
|
||||||
|
const t = anim.elapsed < hitAt
|
||||||
|
? anim.elapsed / hitAt
|
||||||
|
: anim.elapsed < duration
|
||||||
|
? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
|
||||||
|
: 0
|
||||||
|
return {
|
||||||
|
x: anim.startPos.x + (anim.endPos.x - anim.startPos.x) * t,
|
||||||
|
y: anim.startPos.y + (anim.endPos.y - anim.startPos.y) * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch: get the current position of any animation type.
|
||||||
|
* Returns { x, y } or null if the type is unknown.
|
||||||
|
*/
|
||||||
|
function getAnimPos(anim) {
|
||||||
|
switch (anim?.type) {
|
||||||
|
case ANIM_TYPES.SLASH: return getSlashPos(anim)
|
||||||
|
case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim)
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tick a single animation by dt seconds. Returns true when the animation is done.
|
||||||
|
*/
|
||||||
|
function tickAnimation(anim, dt) {
|
||||||
|
const cfg = ANIM_CFG[anim?.type]
|
||||||
|
if (!cfg) return true
|
||||||
|
anim.elapsed += dt
|
||||||
|
return anim.elapsed >= cfg.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the hit/damage logic when an animation reaches its hitAt time.
|
||||||
|
*/
|
||||||
|
function applyAnimHit(anim) {
|
||||||
|
const liveCc = combatRef.current
|
||||||
|
|
||||||
let inRange = false
|
let inRange = false
|
||||||
if (liveCc) {
|
if (liveCc) {
|
||||||
@@ -558,22 +629,24 @@ export default function App() {
|
|||||||
? 'Your attack misses!'
|
? 'Your attack misses!'
|
||||||
: `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!`
|
: `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!`
|
||||||
setCombat(prev => ({ ...prev, log: [...prev.log, msg] }))
|
setCombat(prev => ({ ...prev, log: [...prev.log, msg] }))
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() })
|
setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() })
|
||||||
|
|
||||||
if (anim.actor === 'player') {
|
if (anim.actor === 'player') {
|
||||||
// ── Player attacking enemy ──
|
|
||||||
const wid = anim.weaponId
|
const wid = anim.weaponId
|
||||||
if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') {
|
if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') {
|
||||||
const base = weaponMap[wid]
|
const base = weaponMap[wid]
|
||||||
setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) }))
|
setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt)
|
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt)
|
||||||
|
|
||||||
if (liveCc) {
|
if (liveCc) {
|
||||||
const enemy = liveCc.enemies[anim.enemyIndex]
|
const enemy = liveCc.enemies[anim.enemyIndex]
|
||||||
if (enemy) {
|
if (enemy) {
|
||||||
const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt)
|
const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt)
|
||||||
const prevHp = decayedEnemy[anim.targetPart]
|
const prevHp = decayedEnemy[anim.targetPart]
|
||||||
const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
||||||
const newInjuries = prevHp > 0 && anim.severity > 0
|
const newInjuries = prevHp > 0 && anim.severity > 0
|
||||||
@@ -599,12 +672,11 @@ export default function App() {
|
|||||||
setBodyParts(decayedPlayer)
|
setBodyParts(decayedPlayer)
|
||||||
setBodyInjuries(bodyInjuriesRef.current)
|
setBodyInjuries(bodyInjuriesRef.current)
|
||||||
} else {
|
} else {
|
||||||
// ── Enemy attacking player ──
|
|
||||||
const currentEnemy = liveCc?.enemies[anim.enemyIndex]
|
const currentEnemy = liveCc?.enemies[anim.enemyIndex]
|
||||||
const decayedEnemy = currentEnemy
|
const decayedEnemy = currentEnemy
|
||||||
? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, hitAt)
|
? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt)
|
||||||
: calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt)
|
: calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt)
|
||||||
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt)
|
const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, ANIM_CFG[anim.type].hitAt)
|
||||||
const prevHp = decayedPlayer[anim.targetPart]
|
const prevHp = decayedPlayer[anim.targetPart]
|
||||||
const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
||||||
const newInjuries = prevHp > 0 && anim.severity > 0
|
const newInjuries = prevHp > 0 && anim.severity > 0
|
||||||
@@ -628,26 +700,33 @@ export default function App() {
|
|||||||
setBodyInjuries(newInjuries)
|
setBodyInjuries(newInjuries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (anim.elapsed >= 0.45) {
|
|
||||||
setAttackLerp(prev => anim.actor === 'player'
|
|
||||||
? { ...prev, player: null }
|
|
||||||
: { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: undefined } })
|
|
||||||
setHitShake(null)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance a player move by dt seconds. Returns true when done/blocked.
|
* Advance a player move by dt seconds. Returns true when done/blocked.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Advance a player turn by dt. Returns true when facing the target.
|
||||||
|
*/
|
||||||
|
function tickTurn() {
|
||||||
|
const dir = playerDirRef.current
|
||||||
|
const tgt = playerTargetDirRef.current
|
||||||
|
return dir.dx === tgt.dx && dir.dy === tgt.dy
|
||||||
|
}
|
||||||
|
|
||||||
function tickPlayerMove(anim, dt) {
|
function tickPlayerMove(anim, dt) {
|
||||||
|
const mdx = anim.toX - anim.fromX
|
||||||
|
const mdy = anim.toY - anim.fromY
|
||||||
|
const mdist = Math.sqrt(mdx * mdx + mdy * mdy)
|
||||||
|
const moveDir = mdist > 0 ? { dx: mdx / mdist, dy: mdy / mdist } : { dx: 0, dy: 1 }
|
||||||
|
const facing = playerDirRef.current
|
||||||
|
const dot = Math.max(-1, Math.min(1, facing.dx * moveDir.dx + facing.dy * moveDir.dy))
|
||||||
|
const speedMult = 0.7 + 0.3 * dot
|
||||||
|
const speed = anim.speed * speedMult
|
||||||
|
|
||||||
const steps = Math.max(1, Math.round(dt * 60))
|
const steps = Math.max(1, Math.round(dt * 60))
|
||||||
let cf = { x: anim.fromX, y: anim.fromY }
|
let cf = { x: anim.fromX, y: anim.fromY }
|
||||||
for (let i = 0; i < steps; i++) {
|
for (let i = 0; i < steps; i++) {
|
||||||
const p = computeMove(cf, { x: anim.toX, y: anim.toY }, anim.speed)
|
const p = computeMove(cf, { x: anim.toX, y: anim.toY }, speed)
|
||||||
if (p.x === anim.toX && p.y === anim.toY) {
|
if (p.x === anim.toX && p.y === anim.toY) {
|
||||||
setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true
|
setCombat(c => ({ ...c, playerPos: p })); positionsRef.current.player = p; return true
|
||||||
}
|
}
|
||||||
@@ -693,29 +772,60 @@ export default function App() {
|
|||||||
const dt = (now - lastTime) / 1000
|
const dt = (now - lastTime) / 1000
|
||||||
lastTime = now
|
lastTime = now
|
||||||
|
|
||||||
if (!timerActiveRef.current) return
|
// ── Smooth turn player toward target direction ──
|
||||||
|
const curDir = playerDirRef.current
|
||||||
|
const tgtDir = playerTargetDirRef.current
|
||||||
|
if (curDir.dx !== tgtDir.dx || curDir.dy !== tgtDir.dy) {
|
||||||
|
const curAngle = Math.atan2(curDir.dy, curDir.dx)
|
||||||
|
const tgtAngle = Math.atan2(tgtDir.dy, tgtDir.dx)
|
||||||
|
let delta = tgtAngle - curAngle
|
||||||
|
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||||
|
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||||
|
const maxStep = TURN_SPEED * dt
|
||||||
|
if (Math.abs(delta) <= maxStep) {
|
||||||
|
playerDirRef.current = { dx: tgtDir.dx, dy: tgtDir.dy }
|
||||||
|
} else {
|
||||||
|
const newAngle = curAngle + Math.sign(delta) * maxStep
|
||||||
|
playerDirRef.current = { dx: Math.cos(newAngle), dy: Math.sin(newAngle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setCombatTime(t => t + dt)
|
// ── Effects (run every frame, independent of timer) ──
|
||||||
setDisplayTime(t => t + dt)
|
|
||||||
|
|
||||||
// ── Auto-dismiss hitShake after 0.3s ──
|
|
||||||
setHitShake(prev => {
|
setHitShake(prev => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev
|
return (performance.now() - prev.start) / 1000 >= 0.3 ? null : prev
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Update player action ──
|
if (!timerActiveRef.current) {
|
||||||
if (playerActionRef.current) {
|
setAnimations([])
|
||||||
const anim = playerActionRef.current
|
return
|
||||||
const done = anim.type === 'attack'
|
|
||||||
? tickAttack(anim, dt)
|
|
||||||
: anim.type === 'move'
|
|
||||||
? tickPlayerMove(anim, dt)
|
|
||||||
: true
|
|
||||||
if (done) playerActionRef.current = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Update enemy actions ──
|
setCombatTime(t => t + dt)
|
||||||
|
setDisplayTime(t => t + dt)
|
||||||
|
|
||||||
|
// ── Tick player action ──
|
||||||
|
const activeAnims = []
|
||||||
|
if (playerActionRef.current) {
|
||||||
|
const rawAnim = playerActionRef.current
|
||||||
|
const done = rawAnim.type === 'move'
|
||||||
|
? tickPlayerMove(rawAnim, dt)
|
||||||
|
: rawAnim.type === 'turn'
|
||||||
|
? tickTurn()
|
||||||
|
: tickAnimation(rawAnim, dt)
|
||||||
|
if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
|
||||||
|
rawAnim.hitFired = true
|
||||||
|
applyAnimHit(rawAnim)
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
playerActionRef.current = null
|
||||||
|
setHitShake(null)
|
||||||
|
} else if (ANIM_CFG[rawAnim.type]) {
|
||||||
|
activeAnims.push(rawAnim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tick enemy actions ──
|
||||||
const cc = combatRef.current
|
const cc = combatRef.current
|
||||||
const fresh = cc ? (() => {
|
const fresh = cc ? (() => {
|
||||||
const pp = positionsRef.current.player
|
const pp = positionsRef.current.player
|
||||||
@@ -730,17 +840,19 @@ export default function App() {
|
|||||||
})() : null
|
})() : null
|
||||||
if (cc) {
|
if (cc) {
|
||||||
for (let i = 0; i < cc.enemies.length; i++) {
|
for (let i = 0; i < cc.enemies.length; i++) {
|
||||||
const anim = enemyActionsRef.current[i]
|
const rawAnim = enemyActionsRef.current[i]
|
||||||
if (!anim) continue
|
if (!rawAnim) continue
|
||||||
const done = anim.type === 'attack'
|
const done = rawAnim.type === 'enemyMove'
|
||||||
? tickAttack(anim, dt)
|
? tickEnemyMove(rawAnim, dt)
|
||||||
: anim.type === 'enemyMove'
|
: tickAnimation(rawAnim, dt)
|
||||||
? tickEnemyMove(anim, dt)
|
if (!done && ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
|
||||||
: true
|
rawAnim.hitFired = true
|
||||||
|
applyAnimHit(rawAnim)
|
||||||
|
}
|
||||||
if (done) {
|
if (done) {
|
||||||
if (anim.type === 'enemyMove') {
|
if (rawAnim.type === 'enemyMove') {
|
||||||
const e = fresh?.enemies[i] ?? cc.enemies[i]
|
const e = fresh?.enemies[i] ?? cc.enemies[i]
|
||||||
const finalPos = { x: anim.targets[i].toX, y: anim.targets[i].toY }
|
const finalPos = { x: rawAnim.targets[i].toX, y: rawAnim.targets[i].toY }
|
||||||
if (e && !isDead(e.integrity)) {
|
if (e && !isDead(e.integrity)) {
|
||||||
const dist = Math.sqrt(
|
const dist = Math.sqrt(
|
||||||
((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2
|
((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2
|
||||||
@@ -762,22 +874,24 @@ export default function App() {
|
|||||||
targetParts: { ...bodyPartsRef.current },
|
targetParts: { ...bodyPartsRef.current },
|
||||||
targetInjuries: { ...bodyInjuriesRef.current },
|
targetInjuries: { ...bodyInjuriesRef.current },
|
||||||
}
|
}
|
||||||
enemyActionsRef.current[i] = makeAttackAnim(action, fresh)
|
enemyActionsRef.current[i] = createWeaponStrikeAnim(action, fresh)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
enemyActionsRef.current[i] = null
|
enemyActionsRef.current[i] = null
|
||||||
|
} else {
|
||||||
|
activeAnims.push(rawAnim)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Run enemy AI every frame for idle enemies ──
|
// ── Run enemy AI every frame for idle enemies ──
|
||||||
if (fresh) {
|
if (fresh) {
|
||||||
const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current)
|
const actions = runEnemyAI(fresh, bodyPartsRef.current, bodyInjuriesRef.current, enemyDirsRef.current)
|
||||||
for (const act of actions) {
|
for (const act of actions) {
|
||||||
if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) {
|
if (act.type === 'attack' && !enemyActionsRef.current[act.enemyIndex]) {
|
||||||
enemyActionsRef.current[act.enemyIndex] = makeAttackAnim(act, fresh)
|
enemyActionsRef.current[act.enemyIndex] = createWeaponStrikeAnim(act, fresh)
|
||||||
} else if (act.type === 'enemyMove') {
|
} else if (act.type === 'enemyMove') {
|
||||||
for (const idxStr of Object.keys(act.targets)) {
|
for (const idxStr of Object.keys(act.targets)) {
|
||||||
const idx = parseInt(idxStr)
|
const idx = parseInt(idxStr)
|
||||||
@@ -792,12 +906,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pause timer when player is idle; enemy actions freeze mid-state ──
|
// ── Update rendering state for active animations ──
|
||||||
|
setAnimations(activeAnims)
|
||||||
|
|
||||||
|
// ── Auto-stop timer when an action just completed ──
|
||||||
const playerBusy = playerActionRef.current !== null
|
const playerBusy = playerActionRef.current !== null
|
||||||
if (!playerBusy) {
|
if (wasBusyRef.current && !playerBusy) {
|
||||||
timerActiveRef.current = false
|
timerActiveRef.current = false
|
||||||
setTimerRunning(false)
|
setTimerRunning(false)
|
||||||
}
|
}
|
||||||
|
wasBusyRef.current = playerBusy
|
||||||
setBusy(playerBusy)
|
setBusy(playerBusy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,6 +947,21 @@ export default function App() {
|
|||||||
if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight()
|
if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight()
|
||||||
}, [combat, bodyParts, playerHp])
|
}, [combat, bodyParts, playerHp])
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// KEYBINDS
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.code === 'Space' && combat) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [combat])
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
// SNAPSHOT (recording)
|
// SNAPSHOT (recording)
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -840,7 +973,7 @@ export default function App() {
|
|||||||
combat: JSON.parse(JSON.stringify(combat)),
|
combat: JSON.parse(JSON.stringify(combat)),
|
||||||
bodyParts: JSON.parse(JSON.stringify(bodyParts)),
|
bodyParts: JSON.parse(JSON.stringify(bodyParts)),
|
||||||
bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)),
|
bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)),
|
||||||
attackLerp: JSON.parse(JSON.stringify(attackLerp)),
|
animations: JSON.parse(JSON.stringify(animations)),
|
||||||
hitShake: hitShake ? { ...hitShake } : null,
|
hitShake: hitShake ? { ...hitShake } : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -920,7 +1053,7 @@ export default function App() {
|
|||||||
const rec = fightRecord
|
const rec = fightRecord
|
||||||
const f0 = rec.frames[0]
|
const f0 = rec.frames[0]
|
||||||
setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries)
|
setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries)
|
||||||
setAttackLerp(f0.attackLerp && 'player' in f0.attackLerp ? f0.attackLerp : { player: null, enemies: {} }); setHitShake(f0.hitShake ?? null)
|
setAnimations(Array.isArray(f0.animations) ? f0.animations : []); setHitShake(f0.hitShake ?? null)
|
||||||
setCombatTime(0); setDisplayTime(0)
|
setCombatTime(0); setDisplayTime(0)
|
||||||
replayIdxRef.current = 0
|
replayIdxRef.current = 0
|
||||||
|
|
||||||
@@ -932,7 +1065,7 @@ export default function App() {
|
|||||||
replayIdxRef.current = idx
|
replayIdxRef.current = idx
|
||||||
const f = rec.frames[idx]
|
const f = rec.frames[idx]
|
||||||
setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries)
|
setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries)
|
||||||
setAttackLerp(f.attackLerp && 'player' in f.attackLerp ? f.attackLerp : { player: null, enemies: {} }); setHitShake(f.hitShake ?? null)
|
setAnimations(Array.isArray(f.animations) ? f.animations : []); setHitShake(f.hitShake ?? null)
|
||||||
setCombatTime(f.time)
|
setCombatTime(f.time)
|
||||||
}
|
}
|
||||||
if (idx >= rec.frames.length - 1) return
|
if (idx >= rec.frames.length - 1) return
|
||||||
@@ -976,10 +1109,10 @@ export default function App() {
|
|||||||
enemyActionsRef.current = initCombat.enemies.map(() => null)
|
enemyActionsRef.current = initCombat.enemies.map(() => null)
|
||||||
positionsRef.current = {}
|
positionsRef.current = {}
|
||||||
setSelectedEnemy(0); setReplayActive(false)
|
setSelectedEnemy(0); setReplayActive(false)
|
||||||
setExpandedWeapon('ROOT'); setAttackLerp({ player: null, enemies: {} }); setHitShake(null); setMoveMode(null)
|
setExpandedWeapon('ROOT'); setAnimations([]); setHitShake(null); setMoveMode(null)
|
||||||
recordingRef.current = {
|
recordingRef.current = {
|
||||||
initialCombat: JSON.parse(JSON.stringify(initCombat)),
|
initialCombat: JSON.parse(JSON.stringify(initCombat)),
|
||||||
frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, hitShake: null }],
|
frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), animations: [], hitShake: null }],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,7 +1162,7 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
playerActionRef.current = makeAttackAnim(action, combatRef.current)
|
playerActionRef.current = createWeaponStrikeAnim(action, combatRef.current)
|
||||||
timerActiveRef.current = true; setTimerRunning(true)
|
timerActiveRef.current = true; setTimerRunning(true)
|
||||||
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
||||||
}
|
}
|
||||||
@@ -1038,6 +1171,11 @@ export default function App() {
|
|||||||
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleTimer = () => {
|
||||||
|
timerActiveRef.current = !timerActiveRef.current
|
||||||
|
setTimerRunning(timerActiveRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
/** Execute a player move; timer starts immediately. */
|
/** Execute a player move; timer starts immediately. */
|
||||||
const startMove = (targetX, targetY) => {
|
const startMove = (targetX, targetY) => {
|
||||||
if (!combat || playerActionRef.current) return
|
if (!combat || playerActionRef.current) return
|
||||||
@@ -1046,6 +1184,7 @@ export default function App() {
|
|||||||
if (dist < 1) return
|
if (dist < 1) return
|
||||||
const speed = moveMode === 'walk' ? 1 : 3
|
const speed = moveMode === 'walk' ? 1 : 3
|
||||||
playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed }
|
playerActionRef.current = { type: 'move', fromX, fromY, toX: targetX, toY: targetY, speed }
|
||||||
|
playerTargetDirRef.current = { dx: (targetX - fromX) / dist, dy: (targetY - fromY) / dist }
|
||||||
timerActiveRef.current = true; setTimerRunning(true)
|
timerActiveRef.current = true; setTimerRunning(true)
|
||||||
setMoveMode(null)
|
setMoveMode(null)
|
||||||
}
|
}
|
||||||
@@ -1134,18 +1273,24 @@ export default function App() {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-map">
|
<div className="game-map" onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════ COMBAT PANEL */}
|
{/* ═══════════════════════════════════════════════════════ COMBAT PANEL */}
|
||||||
{combat && (
|
{combat && (
|
||||||
<div className="combat-panel">
|
<div className="combat-panel">
|
||||||
<div className="combat-top-bar">
|
<div className="combat-top-bar">
|
||||||
<div className="combat-timer">
|
<div className="combat-timer">
|
||||||
{timerRunning && (
|
<button className="timer-toggle" onClick={toggleTimer} title={timerRunning ? 'Pause' : 'Play'} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', padding: 0, display: 'flex', alignItems: 'center' }}>
|
||||||
<svg className="timer-icon" viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
{timerRunning ? (
|
||||||
<polygon points="4,2 14,8 4,14" />
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||||
|
<rect x="3" y="2" width="4" height="12" rx="1" /><rect x="9" y="2" width="4" height="12" rx="1" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
|
||||||
|
<polygon points="3,2 14,8 3,14" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')}
|
Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1237,7 +1382,11 @@ export default function App() {
|
|||||||
<div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}>
|
<div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'run' ? null : 'run') }}>
|
||||||
<span className="tree-bullet">○</span><span className="tree-label">Run</span>
|
<span className="tree-bullet">○</span><span className="tree-label">Run</span>
|
||||||
</div>
|
</div>
|
||||||
{moveMode && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to move</div></div>}
|
<div className="tree-node leaf" onClick={() => { if (!busy) setMoveMode(m => m === 'turn' ? null : 'turn') }}>
|
||||||
|
<span className="tree-bullet">○</span><span className="tree-label">Turn</span>
|
||||||
|
</div>
|
||||||
|
{moveMode && moveMode !== 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to move</div></div>}
|
||||||
|
{moveMode === 'turn' && <div className="tree-children" style={{ paddingLeft: 0 }}><div className="move-hint">Click on the mini-map to face a direction</div></div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1285,8 +1434,21 @@ export default function App() {
|
|||||||
if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
|
if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
|
||||||
if (limbSelect || !moveMode) return
|
if (limbSelect || !moveMode) return
|
||||||
const r = e.currentTarget.getBoundingClientRect()
|
const r = e.currentTarget.getBoundingClientRect()
|
||||||
startMove(((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x,
|
const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x
|
||||||
((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y)
|
const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y
|
||||||
|
if (moveMode === 'turn') {
|
||||||
|
const dx = tx - combat.playerPos.x
|
||||||
|
const dy = ty - combat.playerPos.y
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
if (d > 0) {
|
||||||
|
playerTargetDirRef.current = { dx: dx / d, dy: dy / d }
|
||||||
|
playerActionRef.current = { type: 'turn' }
|
||||||
|
timerActiveRef.current = true; setTimerRunning(true)
|
||||||
|
setMoveMode(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startMove(tx, ty)
|
||||||
}}>
|
}}>
|
||||||
<g style={{
|
<g style={{
|
||||||
transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`,
|
transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`,
|
||||||
@@ -1297,15 +1459,13 @@ export default function App() {
|
|||||||
{MAP_BLOCKS.map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)}
|
{MAP_BLOCKS.map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)}
|
||||||
{(combat?.enemies || []).map((enemy, i) => {
|
{(combat?.enemies || []).map((enemy, i) => {
|
||||||
const dead = isDead(enemy.integrity)
|
const dead = isDead(enemy.integrity)
|
||||||
const eLerp = attackLerp.enemies[i]
|
|
||||||
const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy'
|
const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy'
|
||||||
return (
|
return (
|
||||||
<circle key={i}
|
<circle key={i}
|
||||||
cx={eLerp ? eLerp.from.x + (eLerp.to.x - eLerp.from.x) * eLerp.t : (isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x)}
|
cx={isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x}
|
||||||
cy={eLerp ? eLerp.from.y + (eLerp.to.y - eLerp.from.y) * eLerp.t : (isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y)}
|
cy={isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y}
|
||||||
r="5" fill={dead ? '#6b7280' : '#e74c3c'} stroke="#000" strokeWidth="0.5" opacity={dead ? 0.5 : 1}
|
r="5" fill={dead ? '#6b7280' : '#e74c3c'} stroke="#000" strokeWidth="0.5" opacity={dead ? 0.5 : 1}
|
||||||
onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }) }}
|
onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }) }}
|
||||||
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}
|
|
||||||
onMouseLeave={() => setHoverInfo(null)}
|
onMouseLeave={() => setHoverInfo(null)}
|
||||||
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} />
|
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} />
|
||||||
)
|
)
|
||||||
@@ -1315,10 +1475,68 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{combat && (
|
{combat && (
|
||||||
<circle
|
<circle
|
||||||
cx={attackLerp.player ? attackLerp.player.from.x + (attackLerp.player.to.x - attackLerp.player.from.x) * attackLerp.player.t : (hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x)}
|
cx={hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x}
|
||||||
cy={attackLerp.player ? attackLerp.player.from.y + (attackLerp.player.to.y - attackLerp.player.from.y) * attackLerp.player.t : (hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y)}
|
cy={hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y}
|
||||||
r="4" fill={isDead(bodyParts) ? '#6b7280' : '#3498db'} stroke="#000" strokeWidth="0.5" opacity={isDead(bodyParts) ? 0.5 : 1} />
|
r="4" fill={isDead(bodyParts) ? '#6b7280' : '#3498db'} stroke="#000" strokeWidth="0.5" opacity={isDead(bodyParts) ? 0.5 : 1} />
|
||||||
)}
|
)}
|
||||||
|
{/* Weapon circles — player */}
|
||||||
|
{combat && (() => {
|
||||||
|
const anim = animations.find(a => a.actor === 'player' && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE))
|
||||||
|
const pos = anim ? getAnimPos(anim) : null
|
||||||
|
const px = pos ? pos.x : (hitShake && hitShake.actor === 'player' ? combat.playerPos.x + (Math.random() - 0.5) * 1.5 : combat.playerPos.x)
|
||||||
|
const py = pos ? pos.y : (hitShake && hitShake.actor === 'player' ? combat.playerPos.y + (Math.random() - 0.5) * 1.5 : combat.playerPos.y)
|
||||||
|
const dir = playerDirRef.current
|
||||||
|
const lw = equippedWeapons.left
|
||||||
|
const rw = equippedWeapons.right
|
||||||
|
const off = 7
|
||||||
|
const circles = []
|
||||||
|
const both = lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm'
|
||||||
|
if (both) {
|
||||||
|
circles.push(
|
||||||
|
<circle key="pw-both"
|
||||||
|
cx={pos ? px : px + dir.dx * off}
|
||||||
|
cy={pos ? py : py + dir.dy * off}
|
||||||
|
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (lw !== 'fists' && lw !== 'left_arm') {
|
||||||
|
circles.push(
|
||||||
|
<circle key="pw-left"
|
||||||
|
cx={pos ? px : px + dir.dy * off}
|
||||||
|
cy={pos ? py : py + -dir.dx * off}
|
||||||
|
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (rw !== 'fists' && rw !== 'right_arm') {
|
||||||
|
circles.push(
|
||||||
|
<circle key="pw-right"
|
||||||
|
cx={pos ? px : px + -dir.dy * off}
|
||||||
|
cy={pos ? py : py + dir.dx * off}
|
||||||
|
r="3" fill="#fbbf24" stroke="#000" strokeWidth="0.5" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return circles
|
||||||
|
})()}
|
||||||
|
{/* Weapon circles — enemies */}
|
||||||
|
{combat.enemies.map((enemy, i) => {
|
||||||
|
const dead = isDead(enemy.integrity)
|
||||||
|
if (dead) return null
|
||||||
|
const wpn = enemy.weapon
|
||||||
|
if (!wpn || wpn === 'fists') return null
|
||||||
|
const enemyAnim = animations.find(a => a.actor === i && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE))
|
||||||
|
const ePos = enemyAnim ? getAnimPos(enemyAnim) : null
|
||||||
|
const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy'
|
||||||
|
const ex = ePos ? ePos.x : (isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x)
|
||||||
|
const ey = ePos ? ePos.y : (isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y)
|
||||||
|
const dir = enemyDirsRef.current[i] ?? { dx: 0, dy: 1 }
|
||||||
|
return (
|
||||||
|
<circle key={`ew-${i}`}
|
||||||
|
cx={ePos ? ex : ex + dir.dx * 7}
|
||||||
|
cy={ePos ? ey : ey + dir.dy * 7}
|
||||||
|
r="3" fill="#f97316" stroke="#000" strokeWidth="0.5" />
|
||||||
|
)
|
||||||
|
})}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -1485,7 +1703,7 @@ export default function App() {
|
|||||||
<>
|
<>
|
||||||
<svg ref={svgRef} viewBox="0 0 800 600" className="map-svg"
|
<svg ref={svgRef} viewBox="0 0 800 600" className="map-svg"
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
|
>
|
||||||
<rect width="800" height="600" fill="transparent"
|
<rect width="800" height="600" fill="transparent"
|
||||||
onPointerDown={handleBgPointerDown}
|
onPointerDown={handleBgPointerDown}
|
||||||
onPointerMove={handleBgPointerMove}
|
onPointerMove={handleBgPointerMove}
|
||||||
@@ -1504,7 +1722,6 @@ export default function App() {
|
|||||||
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
|
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
|
||||||
onClick={() => setSelected(selected === l.id ? null : l.id)}
|
onClick={() => setSelected(selected === l.id ? null : l.id)}
|
||||||
onMouseEnter={() => setHovered(l.id)}
|
onMouseEnter={() => setHovered(l.id)}
|
||||||
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}
|
|
||||||
onMouseLeave={() => setHovered(null)} />
|
onMouseLeave={() => setHovered(null)} />
|
||||||
<text x={l.x} y={l.y + 6} textAnchor="middle" fill="#5F71C5" fontSize={16} fontWeight="bold" pointerEvents="none">{l.id}</text>
|
<text x={l.x} y={l.y + 6} textAnchor="middle" fill="#5F71C5" fontSize={16} fontWeight="bold" pointerEvents="none">{l.id}</text>
|
||||||
{shops.some(s => s.locationId === l.id) && (
|
{shops.some(s => s.locationId === l.id) && (
|
||||||
@@ -1577,13 +1794,6 @@ export default function App() {
|
|||||||
}}>Fight 2 random enemies</button>
|
}}>Fight 2 random enemies</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hovered && (
|
|
||||||
<div className="tooltip" style={{ left: mousePos.x + 16, top: mousePos.y }}>
|
|
||||||
<div className="tooltip-name">{locMap[hovered].name}</div>
|
|
||||||
<div className="tooltip-desc">{locMap[hovered].description}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="info-panel">
|
<div className="info-panel">
|
||||||
<div className="info-name">{locMap[selected].name}</div>
|
<div className="info-name">{locMap[selected].name}</div>
|
||||||
@@ -1670,7 +1880,7 @@ export default function App() {
|
|||||||
.map(id => characters.find(c => c.id === id))
|
.map(id => characters.find(c => c.id === id))
|
||||||
.filter(c => c && c.location === showLocationModal)
|
.filter(c => c && c.location === showLocationModal)
|
||||||
return corpsesHere.length === 0
|
return corpsesHere.length === 0
|
||||||
? <div className="location-empty">No corpses here.</div>
|
? <div className="location-empty">Nothing of interest.</div>
|
||||||
: corpsesHere.map(c => <div key={c.id} className="location-surrounding-corpse">{c.name}'s corpse</div>)
|
: corpsesHere.map(c => <div key={c.id} className="location-surrounding-corpse">{c.name}'s corpse</div>)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
@@ -1712,7 +1922,7 @@ export default function App() {
|
|||||||
<div className="inventory-overlay"
|
<div className="inventory-overlay"
|
||||||
onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }}
|
onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }}
|
||||||
onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}
|
onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}
|
||||||
onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
|
>
|
||||||
<div className="inventory-panel" onClick={e => e.stopPropagation()}>
|
<div className="inventory-panel" onClick={e => e.stopPropagation()}>
|
||||||
<button className="inv-close" onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X</button>
|
<button className="inv-close" onClick={() => { setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X</button>
|
||||||
<div className="inv-layout">
|
<div className="inv-layout">
|
||||||
|
|||||||
Reference in New Issue
Block a user