diff --git a/src/App.css b/src/App.css index 325c6a7..e3a7c7b 100644 --- a/src/App.css +++ b/src/App.css @@ -2009,7 +2009,7 @@ body { } .location-log-col { - width: 200px; + width: 280px; flex-shrink: 0; display: flex; flex-direction: column; @@ -2017,17 +2017,19 @@ body { } .location-log-title { - color: #6b7280; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - padding: 14px 16px 8px; + padding: 12px 16px; + font-size: 14px; + font-weight: bold; + color: #e0e0e0; + border-bottom: 1px solid #2e303a; } .location-log-content { flex: 1; overflow-y: auto; padding: 0 16px 12px; + min-height: 0; + max-height: 130px; } .location-log-line { @@ -2119,53 +2121,51 @@ body { .location-surrounding-desc { color: #9ca3af; font-size: 13px; - padding: 12px 16px; + padding: 14px 16px; line-height: 1.5; border-bottom: 1px solid #1f2028; } -.location-surrounding-connections { - padding: 8px 16px 12px; - border-bottom: 1px solid #1f2028; -} - -.location-surrounding-shops { - padding: 8px 16px 12px; -} - .location-surrounding-subtitle { color: #6b7280; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; - margin-bottom: 6px; - margin-top: 4px; + padding: 14px 16px 6px; } .location-surrounding-connection { color: #5F71C5; font-size: 13px; - padding: 3px 0; + padding: 10px 16px; + border-bottom: 1px solid #1f2028; } .location-surrounding-shop { color: #e0e0e0; font-size: 13px; - padding: 3px 0; + padding: 10px 16px; + border-bottom: 1px solid #1f2028; +} + +.location-surrounding-corpse { + color: #6b7280; + font-size: 13px; + padding: 10px 16px; + border-bottom: 1px solid #1f2028; } .location-npc-row { display: flex; align-items: center; gap: 12px; - padding: 14px 16px; + padding: 10px 16px; border-bottom: 1px solid #1f2028; - border-radius: 1px; } .location-npc-name { color: #e0e0e0; - font-size: 16px; + font-size: 15px; font-weight: bold; flex: 1; } diff --git a/src/App.jsx b/src/App.jsx index 1e5b05a..0d2075b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -119,6 +119,7 @@ function partColor(hp, max) { function freshBody() { return { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 } } function freshInjuries(){ return { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] } } function totalHp(body) { return Object.values(body).reduce((a, b) => a + b, 0) } +function isDead(integrity) { return !integrity || integrity.head <= 0 || integrity.torso <= 0 } function randomPart() { const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg'] @@ -204,6 +205,7 @@ function computeShove(attackerPos, targetPos) { * Returns { dmg, severity, injType, injLabel } */ function computeHit({ weaponId, skill, weaponConditions, bothMult = 1.0 }) { + if (skill.damageType === 'shove') return { dmg: 0, severity: 0, injType: 'shove', injLabel: 'shove' } const base = weaponMap[weaponId] if (!base) return { dmg: 1, severity: 1, injType: 'cut', injLabel: 'cut' } @@ -280,7 +282,7 @@ function runEnemyAI(combat, playerParts, playerInjuries) { for (let i = 0; i < combat.enemies.length; i++) { const e = combat.enemies[i] - if (e.integrity.head <= 0) continue + if (isDead(e.integrity)) continue const dist = Math.sqrt( (combat.playerPos.x - e.pos.x) ** 2 + (combat.playerPos.y - e.pos.y) ** 2 @@ -360,6 +362,7 @@ export default function App() { const [weaponConditions,setWeaponConditions]= useState({ rusty_sword: 65 }) const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) const [weather, setWeather] = useState('clear') + const [defeatedNpcs, setDefeatedNpcs] = useState([]) // ── ui state const [hovered, setHovered] = useState(null) @@ -407,7 +410,7 @@ export default function App() { const [moveMode, setMoveMode] = useState(null) // ── animation state - const [attackLerp, setAttackLerp] = useState(null) + const [attackLerp, setAttackLerp] = useState({ player: null, enemies: {} }) const [hitShake, setHitShake] = useState(null) const [minimapView, setMinimapView] = useState({ x: 0, y: 0, scale: 1 }) @@ -454,7 +457,7 @@ export default function App() { const playerHp = totalHp(bodyParts) const currentWeight = playerInventory.reduce((sum, id) => sum + (itemMap[id]?.weight ?? 0), 0) const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === playerLoc) - const liveCharacters= characters + const liveCharacters= characters.filter(c => !defeatedNpcs.includes(c.id)) const connectedTo = (locId) => { const result = [] @@ -469,7 +472,7 @@ export default function App() { // TIMER — only ticks while timerActive is true // ───────────────────────────────────────────────────────────────────────── - const LUNGE_DIST = 15 + const LUNGE_DIST = 18 function makeAttackAnim(action, cc) { const isPlayer = action.actor === 'player' @@ -512,20 +515,29 @@ export default function App() { * Advance an attack animation by dt seconds. Returns true when done. */ function tickAttack(anim, dt) { + const liveCc = combatRef.current + 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.12 + const hitAt = 0.15 + const returnAt = 0.30 const t = anim.elapsed < hitAt ? anim.elapsed / hitAt - : anim.elapsed < 0.25 - ? 1 - (anim.elapsed - hitAt) / (0.25 - hitAt) + : anim.elapsed < returnAt + ? 1 - (anim.elapsed - hitAt) / (returnAt - hitAt) : 0 - setAttackLerp({ actor: anim.actor, from: anim.startPos, to: anim.lungeTarget, t, enemyIndex: anim.enemyIndex }) + setAttackLerp(prev => anim.actor === 'player' + ? { ...prev, player: { from: anim.startPos, to: anim.lungeTarget, t } } + : { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: { from: anim.startPos, to: anim.lungeTarget, t } } }) if (anim.elapsed >= hitAt && !anim.hitFired) { anim.hitFired = true - const liveCc = combatRef.current let inRange = false if (liveCc) { if (anim.actor === 'player') { @@ -541,35 +553,38 @@ export default function App() { } } - setHitShake({ enemyIndex: anim.enemyIndex, start: performance.now() }) - if (!inRange) { const msg = anim.actor === 'player' ? 'Your attack misses!' : `${liveCc?.enemies[anim.enemyIndex]?.name ?? 'Enemy'}'s attack misses!` setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) - } else if (anim.actor === 'player') { - // ── Player attacking enemy ── + } else { + setHitShake({ actor: anim.actor, enemyIndex: anim.enemyIndex, start: performance.now() }) + if (anim.actor === 'player') { + // ── Player attacking enemy ── const wid = anim.weaponId if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { const base = weaponMap[wid] setWeaponConditions(prev => ({ ...prev, [wid]: Math.max(0, (prev[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) })) } - const decayedPlayer = calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) + const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) if (liveCc) { const enemy = liveCc.enemies[anim.enemyIndex] if (enemy) { - const decayedEnemy = calcIntegrity(anim.targetParts, anim.targetInjuries, hitAt) - const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, decayedEnemy[anim.targetPart] - anim.dmg) } - const newInjuries = applyInjury(anim.targetInjuries, anim.targetPart, anim.injType, anim.severity) + const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, hitAt) + const prevHp = decayedEnemy[anim.targetPart] + const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const newInjuries = prevHp > 0 && anim.severity > 0 + ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) + : enemy.injuries - const log = [ - `You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`, - ] - if (hitEnemy.head <= 0) { - log.push(`You crush ${enemy.name}'s head!`) + const log = anim.dmg > 0 + ? [`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] + : [] + if (isDead(hitEnemy)) { + if (anim.targetPart === 'head') log.push(`You crush ${enemy.name}'s head!`) log.push(`${enemy.name} is defeated!`) } @@ -582,13 +597,19 @@ export default function App() { } setBodyParts(decayedPlayer) - setBodyInjuries(anim.attackerInjuries) + setBodyInjuries(bodyInjuriesRef.current) } else { // ── Enemy attacking player ── - const decayedEnemy = calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) - - const hitPlayer = { ...anim.targetParts, [anim.targetPart]: Math.max(0, (anim.targetParts[anim.targetPart] ?? 100) - anim.dmg) } - const newInjuries = applyInjury(anim.targetInjuries, anim.targetPart, anim.injType, anim.severity) + const currentEnemy = liveCc?.enemies[anim.enemyIndex] + const decayedEnemy = currentEnemy + ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, hitAt) + : calcIntegrity(anim.attackerParts, anim.attackerInjuries, hitAt) + const decayedPlayer = calcIntegrity(bodyPartsRef.current, bodyInjuriesRef.current, hitAt) + const prevHp = decayedPlayer[anim.targetPart] + const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + const newInjuries = prevHp > 0 && anim.severity > 0 + ? applyInjury(bodyInjuriesRef.current, anim.targetPart, anim.injType, anim.severity) + : bodyInjuriesRef.current if (liveCc) { const enemy = liveCc.enemies[anim.enemyIndex] @@ -597,17 +618,22 @@ export default function App() { setCombat(prev => ({ ...prev, enemies: prev.enemies.map((e, i) => i === anim.enemyIndex ? { ...e, integrity: decayedEnemy } : e), - log: [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`], + log: anim.dmg > 0 + ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] + : prev.log, })) } setBodyParts(hitPlayer) setBodyInjuries(newInjuries) } + } } - if (anim.elapsed >= 0.35) { - setAttackLerp(null) + if (anim.elapsed >= 0.45) { + setAttackLerp(prev => anim.actor === 'player' + ? { ...prev, player: null } + : { ...prev, enemies: { ...prev.enemies, [anim.enemyIndex]: undefined } }) setHitShake(null) return true } @@ -715,7 +741,7 @@ export default function App() { if (anim.type === 'enemyMove') { const e = fresh?.enemies[i] ?? cc.enemies[i] const finalPos = { x: anim.targets[i].toX, y: anim.targets[i].toY } - if (e && e.integrity.head > 0) { + if (e && !isDead(e.integrity)) { const dist = Math.sqrt( ((fresh ?? cc).playerPos.x - finalPos.x) ** 2 + ((fresh ?? cc).playerPos.y - finalPos.y) ** 2 ) @@ -800,7 +826,7 @@ export default function App() { useEffect(() => { if (!combat || replayActive) return - if (playerHp <= 0 || !combat.enemies.some(e => e.integrity.head > 0)) endFight() + if (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) endFight() }, [combat, bodyParts, playerHp]) // ───────────────────────────────────────────────────────────────────────── @@ -814,7 +840,7 @@ export default function App() { combat: JSON.parse(JSON.stringify(combat)), bodyParts: JSON.parse(JSON.stringify(bodyParts)), bodyInjuries: JSON.parse(JSON.stringify(bodyInjuries)), - attackLerp: attackLerp ? { ...attackLerp } : null, + attackLerp: JSON.parse(JSON.stringify(attackLerp)), hitShake: hitShake ? { ...hitShake } : null, }) } @@ -894,7 +920,7 @@ export default function App() { const rec = fightRecord const f0 = rec.frames[0] setCombat(f0.combat); setBodyParts(f0.bodyParts); setBodyInjuries(f0.bodyInjuries) - setAttackLerp(f0.attackLerp ?? null); setHitShake(f0.hitShake ?? null) + setAttackLerp(f0.attackLerp && 'player' in f0.attackLerp ? f0.attackLerp : { player: null, enemies: {} }); setHitShake(f0.hitShake ?? null) setCombatTime(0); setDisplayTime(0) replayIdxRef.current = 0 @@ -906,7 +932,7 @@ export default function App() { replayIdxRef.current = idx const f = rec.frames[idx] setCombat(f.combat); setBodyParts(f.bodyParts); setBodyInjuries(f.bodyInjuries) - setAttackLerp(f.attackLerp ?? null); setHitShake(f.hitShake ?? null) + setAttackLerp(f.attackLerp && 'player' in f.attackLerp ? f.attackLerp : { player: null, enemies: {} }); setHitShake(f.hitShake ?? null) setCombatTime(f.time) } if (idx >= rec.frames.length - 1) return @@ -950,7 +976,7 @@ export default function App() { enemyActionsRef.current = initCombat.enemies.map(() => null) positionsRef.current = {} setSelectedEnemy(0); setReplayActive(false) - setExpandedWeapon('ROOT') + setExpandedWeapon('ROOT'); setAttackLerp({ player: null, enemies: {} }); setHitShake(null); setMoveMode(null) recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), bodyParts: freshBody(), bodyInjuries: freshInjuries(), attackLerp: null, hitShake: null }], @@ -961,29 +987,37 @@ export default function App() { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } timerActiveRef.current = false; setTimerRunning(false) setCombatTime(0); setDisplayTime(0) + playerActionRef.current = null + enemyActionsRef.current = [] + if (combat) { + const deadIds = combat.enemies.filter(e => isDead(e.integrity)).map(e => e.id) + if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) + } } /** Execute a player attack; timer starts immediately. */ const executeAttack = () => { if (!limbSelect || !selectedTarget) return - if (!combat) return + const cc = combatRef.current + if (!cc) return if (playerActionRef.current) return + if (isDead(bodyPartsRef.current)) return const ei = attackEnemy ?? selectedEnemy - const enemy = combat.enemies[ei] - if (!enemy || enemy.integrity.head <= 0) return + const enemy = cc.enemies[ei] + if (!enemy || isDead(enemy.integrity)) return const weaponId = limbSelect - if (!canReach(combat.playerPos, enemy.pos, weaponId, selectedSkill)) return + if (!canReach(cc.playerPos, enemy.pos, weaponId, selectedSkill)) return const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, enemyIndex: ei, equippedWeapons, weaponConditions, - bodyParts, bodyInjuries, combat, + bodyParts: bodyPartsRef.current, bodyInjuries: bodyInjuriesRef.current, combat: cc, }) if (action.type === 'shove_pending') { const enemy2 = combat.enemies[action.enemyIndex] - if (!enemy2 || enemy2.integrity.head <= 0) return + if (!enemy2 || isDead(enemy2.integrity)) return const { pos, actualPush } = computeShove(combat.playerPos, enemy2.pos) setCombat(c => ({ ...c, @@ -1119,7 +1153,7 @@ export default function App() {
{/* ── left: action tree */}
- {playerHp > 0 && combat.enemies.some(e => e.integrity.head > 0) && ( + {!isDead(bodyParts) && combat.enemies.some(e => !isDead(e.integrity)) && (
setExpandedWeapon(expandedWeapon === 'ROOT' ? null : 'ROOT')}> @@ -1215,7 +1249,7 @@ export default function App() { {/* ── center: minimap + log */}
-
+
@@ -1254,17 +1288,23 @@ export default function App() { startMove(((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x, ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y) }}> - + {MAP_BLOCKS.map((block, i) => )} {(combat?.enemies || []).map((enemy, i) => { - const eLerp = attackLerp && attackLerp.actor === 'enemy' && attackLerp.enemyIndex === i + const dead = isDead(enemy.integrity) + const eLerp = attackLerp.enemies[i] + const isShaking = hitShake && hitShake.enemyIndex === i && hitShake.actor === 'enemy' return ( { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed }) }} + 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)} + 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)} + 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) }) }} onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })} onMouseLeave={() => setHoverInfo(null)} onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(i) }} /> @@ -1275,9 +1315,9 @@ export default function App() { )} {combat && ( + 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)} + 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)} + r="4" fill={isDead(bodyParts) ? '#6b7280' : '#3498db'} stroke="#000" strokeWidth="0.5" opacity={isDead(bodyParts) ? 0.5 : 1} /> )} @@ -1420,15 +1460,15 @@ export default function App() {
+ disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || isDead(bodyParts)} + style={{ opacity: (!selectedSkill || attackEnemy === null || !selectedTarget || busy || isDead(bodyParts)) ? 0.4 : 1 }}>Confirm
)} {/* ── combat over */} - {combat && (playerHp <= 0 || !combat.enemies.some(e => e.integrity.head > 0)) && ( + {combat && (isDead(bodyParts) || !combat.enemies.some(e => !isDead(e.integrity))) && (
Combat Over
@@ -1538,7 +1578,7 @@ export default function App() {
{hovered && ( -
+
{locMap[hovered].name}
{locMap[hovered].description}
@@ -1603,25 +1643,36 @@ export default function App() {
{locationLog.map((line, i) =>
{line}
)}
+
+
Description
+
+
{locMap[showLocationModal]?.description}
+
Connections
+ {connections.filter(c => c.from === showLocationModal || c.to === showLocationModal).map(c => { + const id = c.from === showLocationModal ? c.to : c.from + return
{locMap[id]?.name}
+ })} + {shops.filter(s => s.locationId === showLocationModal).length > 0 && ( + <> +
Shops
+ {shops.filter(s => s.locationId === showLocationModal).map(s =>
{s.name}
)} + + )} +
+
Surroundings
-
{locMap[showLocationModal]?.description}
-
-
Connections
- {connections.filter(c => c.from === showLocationModal || c.to === showLocationModal).map(c => { - const id = c.from === showLocationModal ? c.to : c.from - return
{locMap[id]?.name}
- })} -
- {shops.filter(s => s.locationId === showLocationModal).length > 0 && ( -
-
Shops
- {shops.filter(s => s.locationId === showLocationModal).map(s =>
{s.name}
)} -
- )} + {(() => { + const corpsesHere = defeatedNpcs + .map(id => characters.find(c => c.id === id)) + .filter(c => c && c.location === showLocationModal) + return corpsesHere.length === 0 + ?
No corpses here.
+ : corpsesHere.map(c =>
{c.name}'s corpse
) + })()}
@@ -1874,19 +1925,19 @@ export default function App() { {/* ═══════════════════════════════════════════════════════ TOOLTIPS */} {hovered && ( -
+
{locMap[hovered].name}
{locMap[hovered].description}
)} {hoverInfo?.type === 'item' && itemMap[hoverInfo.id] && ( -
+
{itemMap[hoverInfo.id].name}
{itemMap[hoverInfo.id].description}
)} {hoverInfo?.type === 'weapon' && weaponMap[hoverInfo.id] && ( -
+
{hoverInfo.id === 'left_arm' || hoverInfo.id === 'right_arm' ? <>
{weaponMap[hoverInfo.id].name}
Unarmed strike (DMG: 10)
: <>
{weaponMap[hoverInfo.id].name}
DMG: {weaponMap[hoverInfo.id].damage} SPD: {weaponMap[hoverInfo.id].speed}
@@ -1897,7 +1948,7 @@ export default function App() {
)} {hoverInfo?.type === 'enemy' && ( -
+
{hoverInfo.name}
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}