pre physics
This commit is contained in:
+47
@@ -1709,6 +1709,53 @@ body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.cap-breakdown {
|
||||
padding: 2px 0 6px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cap-breakdown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cap-bd-color {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cap-bd-label {
|
||||
color: #9ca3af;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cap-bd-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #2e303a;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cap-bd-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.cap-bd-hp {
|
||||
color: #6b7280;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.part-injuries {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
+495
-122
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect, Fragment } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect, useLayoutEffect, Fragment } from 'react'
|
||||
import './App.css'
|
||||
import maleBody from './assets/body/malebody.png'
|
||||
import pistolShotMp3 from './assets/pistolshot.mp3'
|
||||
@@ -37,12 +37,12 @@ const characters = [
|
||||
|
||||
const weapons = [
|
||||
{ id: 'fists', name: 'Fists', damage: 35, condition: 100, maxCondition: 100, passives: ['Brawler'], weight: 0, speed: 14, skills: [{ name: 'Jab', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Hook', mult: 1.3, damageType: 'blunt', range: 12 }, { name: 'Kick', mult: 1.5, damageType: 'blunt', range: 14 }, { name: 'Shove', mult: 0, damageType: 'shove', range: 10 }] },
|
||||
{ id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, compatibleBulletSizes: ['9mm'], skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }] },
|
||||
{ id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }] },
|
||||
{ id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }] },
|
||||
{ id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }] },
|
||||
{ id: 'dagger', name: 'Dagger', damage: 50, condition: 100, maxCondition: 100, passives: ['Quick', 'Bleed'], weight: 2, speed: 13, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 12 }, { name: 'Stab', mult: 1.3, damageType: 'pierce', range: 15 }] },
|
||||
{ id: 'warhammer', name: 'Warhammer', damage: 100, condition: 100, maxCondition: 100, passives: ['Crush', 'Stagger'], weight: 8, speed: 6, skills: [{ name: 'Pound', mult: 1.0, damageType: 'blunt', range: 14 }, { name: 'Slam', mult: 1.4, damageType: 'blunt', range: 13 }] },
|
||||
{ id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, compatibleBulletSizes: ['9mm'], skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Load', mult: 0, damageType: 'reload', range: 0 }, { name: 'Unload', mult: 0, damageType: 'unload', range: 0 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
{ id: 'rusty_sword', name: 'Rusty Sword', damage: 60, condition: 65, maxCondition: 100, passives: ['Bleed'], weight: 4, speed: 10, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
{ id: 'steel_sword', name: 'Steel Sword', damage: 85, condition: 100, maxCondition: 100, passives: ['Bleed', 'Pierce'], weight: 5, speed: 9, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 15 }, { name: 'Stab', mult: 1.2, damageType: 'pierce', range: 18 }, { name: 'Overhead', mult: 1.4, damageType: 'slash', range: 14 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
{ id: 'iron_shield', name: 'Iron Shield', damage: 40, condition: 100, maxCondition: 100, passives: ['Block', 'Sturdy'], weight: 6, speed: 7, skills: [{ name: 'Bash', mult: 1.0, damageType: 'blunt', range: 12 }, { name: 'Shield Slam', mult: 1.3, damageType: 'blunt', range: 13 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
{ id: 'dagger', name: 'Dagger', damage: 50, condition: 100, maxCondition: 100, passives: ['Quick', 'Bleed'], weight: 2, speed: 13, skills: [{ name: 'Slash', mult: 1.0, damageType: 'slash', range: 12 }, { name: 'Stab', mult: 1.3, damageType: 'pierce', range: 15 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
{ id: 'warhammer', name: 'Warhammer', damage: 100, condition: 100, maxCondition: 100, passives: ['Crush', 'Stagger'], weight: 8, speed: 6, skills: [{ name: 'Pound', mult: 1.0, damageType: 'blunt', range: 14 }, { name: 'Slam', mult: 1.4, damageType: 'blunt', range: 13 }, { name: 'Drop', mult: 0, damageType: 'drop', range: 0 }] },
|
||||
]
|
||||
|
||||
const items = [
|
||||
@@ -64,23 +64,54 @@ weaponMap['right_arm'] = { id: 'right_arm', name: 'Right Arm', ...ARM_WEAPON }
|
||||
// CONSTANTS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_BODY = { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg: 100, rightLeg: 100 }
|
||||
const BODY_TREE = {
|
||||
head: {},
|
||||
torso: {
|
||||
leftUpperArm: { leftForearm: { leftHand: { leftFingers: {} } } },
|
||||
rightUpperArm: { rightForearm: { rightHand: { rightFingers: {} } } },
|
||||
leftUpperLeg: { leftLowerLeg: { leftFoot: { leftToes: {} } } },
|
||||
rightUpperLeg: { rightLowerLeg: { rightFoot: { rightToes: {} } } },
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_PARTS = (function() {
|
||||
const parts = []
|
||||
function walk(node) { for (const k of Object.keys(node)) { parts.push(k); if (Object.keys(node[k]).length > 0) walk(node[k]) } }
|
||||
walk(BODY_TREE)
|
||||
return parts
|
||||
})()
|
||||
|
||||
const BODY_CHILDREN = (function() {
|
||||
const map = {}
|
||||
function walk(node, parent) { for (const [k, v] of Object.entries(node)) { map[k] = Object.keys(v); if (Object.keys(v).length > 0) walk(v, k) } }
|
||||
walk(BODY_TREE)
|
||||
return map
|
||||
})()
|
||||
|
||||
const BODY_DESCENDANTS = {}
|
||||
for (const part of ALL_PARTS) {
|
||||
const descs = []
|
||||
function walk(p) { for (const c of BODY_CHILDREN[p] || []) { descs.push(c); walk(c) } }
|
||||
walk(part)
|
||||
BODY_DESCENDANTS[part] = descs
|
||||
}
|
||||
|
||||
const MAX_BODY = {
|
||||
head: 80, torso: 200,
|
||||
leftUpperArm: 70, leftForearm: 50, leftHand: 30, leftFingers: 15,
|
||||
rightUpperArm: 70, rightForearm: 50, rightHand: 30, rightFingers: 15,
|
||||
leftUpperLeg: 100, leftLowerLeg: 70, leftFoot: 40, leftToes: 15,
|
||||
rightUpperLeg: 100, rightLowerLeg: 70, rightFoot: 40, rightToes: 15,
|
||||
}
|
||||
const MAX_HP = Object.values(MAX_BODY).reduce((a, b) => a + b, 0)
|
||||
const MAX_WEIGHT = 50
|
||||
|
||||
function generateLocationBlocks(locationId) {
|
||||
let seed = 0
|
||||
for (let i = 0; i < locationId.length; i++) {
|
||||
seed = ((seed << 5) - seed) + locationId.charCodeAt(i)
|
||||
seed = seed & seed
|
||||
}
|
||||
let s = seed
|
||||
function rng() { s = (s * 1664525 + 1013904223) & 0xFFFFFFFF; return (s >>> 0) / 0xFFFFFFFF }
|
||||
function generateLocationBlocks() {
|
||||
const blocks = []
|
||||
const num = 3 + Math.floor(rng() * 3)
|
||||
const num = 3 + Math.floor(Math.random() * 4)
|
||||
for (let i = 0; i < num; i++) {
|
||||
const w = 15 + Math.floor(rng() * 25); const h = 15 + Math.floor(rng() * 35)
|
||||
const x = 5 + Math.floor(rng() * (150 - w - 5)); const y = 5 + Math.floor(rng() * (150 - h - 5))
|
||||
const w = 10 + Math.floor(Math.random() * 30); const h = 10 + Math.floor(Math.random() * 40)
|
||||
const x = 5 + Math.floor(Math.random() * (150 - w - 5)); const y = 5 + Math.floor(Math.random() * (150 - h - 5))
|
||||
blocks.push({ x, y, w, h })
|
||||
}
|
||||
return blocks
|
||||
@@ -98,13 +129,16 @@ function getItemValue(id) {
|
||||
|
||||
const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 }
|
||||
const TURN_SPEED = 6
|
||||
const FRICTION = 3
|
||||
const CURRENCY = 'ahn'
|
||||
const PISTOL_MAG = 7
|
||||
|
||||
const bodyPartLabels = {
|
||||
head: 'Head', torso: 'Torso',
|
||||
leftArm: 'Left Arm', rightArm: 'Right Arm',
|
||||
leftLeg: 'Left Leg', rightLeg: 'Right Leg',
|
||||
leftUpperArm: 'Left Upper Arm', leftForearm: 'Left Forearm', leftHand: 'Left Hand', leftFingers: 'Left Fingers',
|
||||
rightUpperArm: 'Right Upper Arm', rightForearm: 'Right Forearm', rightHand: 'Right Hand', rightFingers: 'Right Fingers',
|
||||
leftUpperLeg: 'Left Upper Leg', leftLowerLeg: 'Left Lower Leg', leftFoot: 'Left Foot', leftToes: 'Left Toes',
|
||||
rightUpperLeg: 'Right Upper Leg', rightLowerLeg: 'Right Lower Leg', rightFoot: 'Right Foot', rightToes: 'Right Toes',
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -165,10 +199,21 @@ function partColor(hp, max) {
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
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 freshBody() { return { ...MAX_BODY } }
|
||||
function freshInjuries(){ return Object.fromEntries(ALL_PARTS.map(p => [p, []])) }
|
||||
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 isDead(integrity) { return !integrity || integrity.head <= 0 }
|
||||
function propagateDestruction(integrity) {
|
||||
const out = { ...integrity }
|
||||
for (const part of ALL_PARTS) {
|
||||
if ((out[part] ?? 100) <= 0) {
|
||||
for (const desc of BODY_DESCENDANTS[part]) {
|
||||
if (out[desc] !== undefined) out[desc] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function createCharacter(data) {
|
||||
const weaponId = data.weapon ?? 'fists'
|
||||
@@ -190,6 +235,7 @@ function createCharacter(data) {
|
||||
fillColor: data.fillColor ?? `hsl(${Math.random() * 360}, 70%, 55%)`,
|
||||
weapon: weaponId,
|
||||
pos: data.pos ? { x: data.pos.x, y: data.pos.y } : undefined,
|
||||
vel: { x: 0, y: 0 },
|
||||
speed: (data.speed ?? wpn?.speed ?? 10) / 2,
|
||||
maxSpeed: data.maxSpeed ?? 2,
|
||||
attack: data.attack ?? wpn?.damage ?? 30,
|
||||
@@ -199,8 +245,7 @@ function createCharacter(data) {
|
||||
}
|
||||
|
||||
function randomPart() {
|
||||
const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg']
|
||||
return parts[Math.floor(Math.random() * parts.length)]
|
||||
return ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)]
|
||||
}
|
||||
|
||||
function injuryType(damageType) {
|
||||
@@ -220,7 +265,7 @@ function calcIntegrity(integrity, injuries, deltaTime = 1) {
|
||||
}
|
||||
const out = {}
|
||||
for (const part of Object.keys(integrity)) out[part] = Math.max(0, integrity[part] - drain * deltaTime)
|
||||
return out
|
||||
return propagateDestruction(out)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -391,10 +436,7 @@ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) {
|
||||
if (minDist > maxRange) {
|
||||
// out of range → move toward target
|
||||
const speed = e.speed > 12 ? 10 : 5
|
||||
const newPos = computeMove(e.pos, targetPos, speed, combat.blocks)
|
||||
if (newPos.x !== e.pos.x || newPos.y !== e.pos.y) {
|
||||
actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: newPos.x, toY: newPos.y, speed })
|
||||
}
|
||||
actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: targetPos.x, toY: targetPos.y, speed })
|
||||
continue
|
||||
}
|
||||
const skill = skills[Math.floor(Math.random() * skills.length)]
|
||||
@@ -424,12 +466,20 @@ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bodyOverlays = [
|
||||
{ part: 'head', left: '22%', top: '1%', width: '56%', height: '20%' },
|
||||
{ part: 'torso', left: '18%', top: '22%', width: '64%', height: '35%' },
|
||||
{ part: 'leftArm', left: '2%', top: '22%', width: '20%', height: '34%' },
|
||||
{ part: 'rightArm', left: '78%', top: '22%', width: '20%', height: '34%' },
|
||||
{ part: 'leftLeg', left: '20%', top: '58%', width: '30%', height: '36%' },
|
||||
{ part: 'rightLeg', left: '50%', top: '58%', width: '30%', height: '36%' },
|
||||
{ part: 'head', left: '22%', top: '1%', width: '56%', height: '20%' },
|
||||
{ part: 'torso', left: '18%', top: '22%', width: '64%', height: '35%' },
|
||||
{ part: 'leftUpperArm', left: '2%', top: '22%', width: '20%', height: '14%' },
|
||||
{ part: 'leftForearm', left: '2%', top: '36%', width: '16%', height: '12%' },
|
||||
{ part: 'leftHand', left: '3%', top: '48%', width: '14%', height: '8%' },
|
||||
{ part: 'rightUpperArm', left: '78%', top: '22%', width: '20%', height: '14%' },
|
||||
{ part: 'rightForearm', left: '82%', top: '36%', width: '16%', height: '12%' },
|
||||
{ part: 'rightHand', left: '83%', top: '48%', width: '14%', height: '8%' },
|
||||
{ part: 'leftUpperLeg', left: '20%', top: '58%', width: '30%', height: '16%' },
|
||||
{ part: 'leftLowerLeg', left: '22%', top: '74%', width: '26%', height: '14%' },
|
||||
{ part: 'leftFoot', left: '24%', top: '88%', width: '22%', height: '8%' },
|
||||
{ part: 'rightUpperLeg', left: '50%', top: '58%', width: '30%', height: '16%' },
|
||||
{ part: 'rightLowerLeg', left: '52%', top: '74%', width: '26%', height: '14%' },
|
||||
{ part: 'rightFoot', left: '54%', top: '88%', width: '22%', height: '8%' },
|
||||
]
|
||||
|
||||
function BodyImage({ body }) {
|
||||
@@ -523,6 +573,9 @@ export default function App() {
|
||||
const [weather, setWeather] = useState('clear')
|
||||
const [defeatedNpcs, setDefeatedNpcs] = useState([])
|
||||
const [characterOverrides, setCharacterOverrides] = useState({})
|
||||
const worldItemsRef = useRef({})
|
||||
const groundItemsRef = useRef([])
|
||||
const savedMapsRef = useRef({})
|
||||
const liveCharacters = characters.map(c => ({ ...c, ...characterOverrides[c.id] })).filter(c => !defeatedNpcs.includes(c.id))
|
||||
|
||||
// ── ui state
|
||||
@@ -590,8 +643,12 @@ export default function App() {
|
||||
// ── projectiles (modular projectile framework)
|
||||
const [projectiles, setProjectiles] = useState([])
|
||||
|
||||
// ── ground items
|
||||
const [groundItems, setGroundItems] = useState([])
|
||||
|
||||
// ── hovered character for arrow glow
|
||||
const [hoveredChar, setHoveredChar] = useState(null)
|
||||
const [hoveredCap, setHoveredCap] = useState(null)
|
||||
|
||||
// ── replay
|
||||
const [fightRecord, setFightRecord] = useState(null)
|
||||
@@ -631,6 +688,7 @@ export default function App() {
|
||||
const characterActionsRef = useRef({}) // { [charId]: action }
|
||||
const characterDirsRef = useRef({}) // { [charId]: {dx,dy} }
|
||||
const characterPositionsRef = useRef({}) // { [charId]: {x,y} }
|
||||
const characterVelocitiesRef = useRef({}) // { [charId]: {x,y} }
|
||||
|
||||
const recordingRef = useRef(null)
|
||||
const viewRef = useRef(null); viewRef.current = view
|
||||
@@ -693,6 +751,7 @@ export default function App() {
|
||||
FIST_STRIKE: 'fist_strike',
|
||||
FIST_HOOK: 'fist_hook',
|
||||
SHOOT: 'shoot',
|
||||
OVERHEAD: 'overhead',
|
||||
}
|
||||
|
||||
const ANIM_CFG = {
|
||||
@@ -701,6 +760,7 @@ export default function App() {
|
||||
[ANIM_TYPES.FIST_STRIKE]: { duration: 0.25, hitAt: 0.1 },
|
||||
[ANIM_TYPES.FIST_HOOK]: { duration: 0.3, hitAt: 0.15 },
|
||||
[ANIM_TYPES.SHOOT]: { duration: 0.5, hitAt: 0.5 },
|
||||
[ANIM_TYPES.OVERHEAD]: { duration: 0.35, hitAt: 0.18 },
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -821,8 +881,11 @@ export default function App() {
|
||||
*/
|
||||
function createWeaponStrikeAnim(action, cc) {
|
||||
if (!cc) return null
|
||||
const isPlayerSide = action.actor === 'player' || action.actor === 'party'
|
||||
const attackerId = action.charId
|
||||
if (characterVelocitiesRef.current[attackerId]) {
|
||||
characterVelocitiesRef.current[attackerId] = { x: 0, y: 0 }
|
||||
}
|
||||
const isPlayerSide = action.actor === 'player' || action.actor === 'party'
|
||||
const targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : (cc.partyIds?.[0] ?? cc.playerEntityId))
|
||||
const attacker = cc.characters[attackerId]
|
||||
const target = cc.characters[targetId]
|
||||
@@ -835,6 +898,7 @@ export default function App() {
|
||||
const isFist = action.weaponId === 'fists' || action.weaponId === 'left_arm' || action.weaponId === 'right_arm'
|
||||
const type = isFist
|
||||
? (action.weaponId === 'right_arm' ? ANIM_TYPES.FIST_HOOK : ANIM_TYPES.FIST_STRIKE)
|
||||
: action.skillName === 'Overhead' ? ANIM_TYPES.OVERHEAD
|
||||
: damageType === 'slash' ? ANIM_TYPES.SLASH
|
||||
: ANIM_TYPES.PIERCE_STRIKE
|
||||
|
||||
@@ -927,30 +991,57 @@ export default function App() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fist hook: wide curved arc — sweeps out to the side then into the target.
|
||||
* Fist hook: sweeps in a wide arc toward the enemy via a quadratic bezier.
|
||||
*/
|
||||
function getFistHookPos(anim) {
|
||||
const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.FIST_HOOK]
|
||||
const t = anim.elapsed < hitAt
|
||||
? anim.elapsed / hitAt
|
||||
: anim.elapsed < duration
|
||||
? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
|
||||
: 0
|
||||
|
||||
const mid = {
|
||||
x: (anim.startPos.x + anim.endPos.x) / 2,
|
||||
y: (anim.startPos.y + anim.endPos.y) / 2,
|
||||
}
|
||||
const dx = anim.endPos.x - anim.startPos.x
|
||||
const dy = anim.endPos.y - anim.startPos.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const nx = -dy / dist
|
||||
const ny = dx / dist
|
||||
const arcWidth = Math.sin(t * Math.PI) * 12
|
||||
|
||||
// Control point offset perpendicular to the attack line — creates the hook arc
|
||||
const cp = {
|
||||
x: (anim.startPos.x + anim.endPos.x) / 2 + nx * Math.min(dist * 0.4, 20),
|
||||
y: (anim.startPos.y + anim.endPos.y) / 2 + ny * Math.min(dist * 0.4, 20),
|
||||
}
|
||||
|
||||
// Forward phase: bezier curve from start → cp → end
|
||||
if (anim.elapsed < hitAt) {
|
||||
const t = anim.elapsed / hitAt
|
||||
const u = 1 - t
|
||||
return {
|
||||
x: u * u * anim.startPos.x + 2 * u * t * cp.x + t * t * anim.endPos.x,
|
||||
y: u * u * anim.startPos.y + 2 * u * t * cp.y + t * t * anim.endPos.y,
|
||||
}
|
||||
}
|
||||
// Return phase: ease back to start
|
||||
if (anim.elapsed < duration) {
|
||||
const t = (anim.elapsed - hitAt) / (duration - hitAt)
|
||||
const ease = t * t
|
||||
return {
|
||||
x: anim.endPos.x + (anim.startPos.x - anim.endPos.x) * ease,
|
||||
y: anim.endPos.y + (anim.startPos.y - anim.endPos.y) * ease,
|
||||
}
|
||||
}
|
||||
return { ...anim.startPos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Overhead: straight vertical chop toward the target.
|
||||
*/
|
||||
function getOverheadPos(anim) {
|
||||
const { hitAt, duration } = ANIM_CFG[ANIM_TYPES.OVERHEAD]
|
||||
const t = anim.elapsed < hitAt
|
||||
? anim.elapsed / hitAt
|
||||
: anim.elapsed < duration
|
||||
? 1 - (anim.elapsed - hitAt) / (duration - hitAt)
|
||||
: 0
|
||||
return {
|
||||
x: anim.startPos.x + dx * t + nx * arcWidth,
|
||||
y: anim.startPos.y + dy * t + ny * arcWidth,
|
||||
x: anim.startPos.x + (anim.endPos.x - anim.startPos.x) * t,
|
||||
y: anim.startPos.y + (anim.endPos.y - anim.startPos.y) * t,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,16 +1073,16 @@ export default function App() {
|
||||
bothMult: 1.0,
|
||||
})
|
||||
|
||||
const parts = ['head', 'torso', 'leftArm', 'rightArm', 'leftLeg', 'rightLeg']
|
||||
const part = parts[Math.floor(Math.random() * parts.length)]
|
||||
const part = ALL_PARTS[Math.floor(Math.random() * ALL_PARTS.length)]
|
||||
|
||||
setCombat(c => {
|
||||
const characters = { ...c.characters }
|
||||
const e = { ...characters[charId] }
|
||||
const integrity = { ...e.integrity }
|
||||
const injuries = { ...(e.injuries ?? { head: [], torso: [], leftArm: [], rightArm: [], leftLeg: [], rightLeg: [] }) }
|
||||
const injuries = { ...(e.injuries ?? freshInjuries()) }
|
||||
|
||||
integrity[part] = Math.max(0, (integrity[part] ?? 100) - dmg)
|
||||
Object.assign(integrity, propagateDestruction(integrity))
|
||||
if (integrity[part] > 0 && severity > 0) {
|
||||
injuries[part] = [...(injuries[part] ?? []), { type: injType, severity }]
|
||||
}
|
||||
@@ -1040,7 +1131,7 @@ export default function App() {
|
||||
setCombat(c => ({ ...c, log: [...c.log, `Too far`] }))
|
||||
return
|
||||
}
|
||||
const targetPart = 'torso'
|
||||
const targetPart = randomPart()
|
||||
const enemyIndex = (cc.enemyIds ?? []).indexOf(charId)
|
||||
const action = buildPlayerAttack({
|
||||
weaponId, skillName, targetPart,
|
||||
@@ -1057,12 +1148,12 @@ export default function App() {
|
||||
if (action.type === 'shove_pending') {
|
||||
const enemy2 = cc.characters[action.enemyCharId]
|
||||
if (!enemy2 || isDead(enemy2.integrity)) return
|
||||
const { pos, actualPush } = computeShove(actorPos, enemy2.pos, cc.blocks)
|
||||
setCombat(c => ({
|
||||
...c,
|
||||
characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } },
|
||||
log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`],
|
||||
}))
|
||||
const dx = enemy2.pos.x - actorPos.x
|
||||
const dy = enemy2.pos.y - actorPos.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const pushSpeed = 60 + Math.random() * 20
|
||||
characterVelocitiesRef.current[action.enemyCharId] = { x: (dx / dist) * pushSpeed, y: (dy / dist) * pushSpeed }
|
||||
setCombat(c => ({ ...c, log: [...c.log, `${actorChar.name} shoves ${enemy2.name}!`] }))
|
||||
setLimbSelect(null); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]); setAimMode(false)
|
||||
return
|
||||
}
|
||||
@@ -1081,6 +1172,7 @@ export default function App() {
|
||||
case ANIM_TYPES.PIERCE_STRIKE: return getPierceStrikePos(anim)
|
||||
case ANIM_TYPES.FIST_STRIKE: return getFistStrikePos(anim)
|
||||
case ANIM_TYPES.FIST_HOOK: return getFistHookPos(anim)
|
||||
case ANIM_TYPES.OVERHEAD: return getOverheadPos(anim)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
@@ -1141,15 +1233,20 @@ class ParallelAction {
|
||||
}
|
||||
|
||||
class MoveAction {
|
||||
constructor(data) { Object.assign(this, data); this.cx = this.fromX; this.cy = this.fromY }
|
||||
constructor(data) { Object.assign(this, data) }
|
||||
step(dt) {
|
||||
const steps = Math.max(1, Math.round(dt * 60)); const blk = this.blocks ?? []
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const next = computeMove({ x: this.cx, y: this.cy }, { x: this.toX, y: this.toY }, this.speed * dt * 60 / steps, blk)
|
||||
if (next.x === this.cx && next.y === this.cy) return true
|
||||
this.cx = next.x; this.cy = next.y
|
||||
this.setPos(next)
|
||||
if (next.x === this.toX && next.y === this.toY) return true
|
||||
const pos = characterPositionsRef.current[this.charId]
|
||||
if (!pos) return true
|
||||
const dx = this.toX - pos.x
|
||||
const dy = this.toY - pos.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist < 1.5) {
|
||||
characterVelocitiesRef.current[this.charId] = { x: 0, y: 0 }
|
||||
return true
|
||||
}
|
||||
characterVelocitiesRef.current[this.charId] = {
|
||||
x: (dx / dist) * this.speed * 60,
|
||||
y: (dy / dist) * this.speed * 60,
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1230,7 +1327,7 @@ class MoveAction {
|
||||
if (enemy) {
|
||||
const decayedEnemy = calcIntegrity(enemy.integrity, enemy.injuries, ANIM_CFG[anim.type].hitAt)
|
||||
const prevHp = decayedEnemy[anim.targetPart]
|
||||
const hitEnemy = { ...decayedEnemy, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
||||
const hitEnemy = propagateDestruction({ ...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
|
||||
@@ -1266,7 +1363,7 @@ class MoveAction {
|
||||
|
||||
const decayedTarget = calcIntegrity(targetIntegrity, targetInjuries, ANIM_CFG[anim.type].hitAt)
|
||||
const prevHp = decayedTarget[anim.targetPart]
|
||||
const hitTarget = { ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) }
|
||||
const hitTarget = propagateDestruction({ ...decayedTarget, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) })
|
||||
const newInjuries = prevHp > 0 && anim.severity > 0
|
||||
? applyInjury(targetInjuries, anim.targetPart, anim.injType, anim.severity)
|
||||
: targetInjuries
|
||||
@@ -1297,6 +1394,58 @@ class MoveAction {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// PHYSICS — integrates velocities, resolves collisions, applies friction
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stepPhysics(dt, blocks) {
|
||||
const blk = blocks ?? []
|
||||
const updates = {}
|
||||
for (const cid of Object.keys(characterVelocitiesRef.current)) {
|
||||
const vel = characterVelocitiesRef.current[cid]
|
||||
if (!vel || (vel.x === 0 && vel.y === 0)) continue
|
||||
const pos = characterPositionsRef.current[cid]
|
||||
if (!pos) continue
|
||||
let nx = pos.x, ny = pos.y
|
||||
const travel = Math.max(Math.abs(vel.x * dt), Math.abs(vel.y * dt))
|
||||
const subSteps = Math.max(1, Math.ceil(travel / 2))
|
||||
const subDt = dt / subSteps
|
||||
for (let i = 0; i < subSteps; i++) {
|
||||
let sx = nx + vel.x * subDt
|
||||
let sy = ny + vel.y * subDt
|
||||
const hitBlock = blk.find(b => rectCollides(sx, sy, b))
|
||||
if (hitBlock) {
|
||||
if (!blk.some(b => rectCollides(sx, ny, b))) {
|
||||
sy = ny + vel.y * subDt
|
||||
} else if (!blk.some(b => rectCollides(nx, sy, b))) {
|
||||
sx = nx + vel.x * subDt
|
||||
} else {
|
||||
vel.x = 0; vel.y = 0; break
|
||||
}
|
||||
}
|
||||
nx = Math.max(5, Math.min(145, sx))
|
||||
ny = Math.max(5, Math.min(145, sy))
|
||||
}
|
||||
pos.x = nx; pos.y = ny
|
||||
updates[cid] = { x: nx, y: ny }
|
||||
vel.x /= (1 + FRICTION * dt)
|
||||
vel.y /= (1 + FRICTION * dt)
|
||||
if (Math.abs(vel.x) < 0.01) vel.x = 0
|
||||
if (Math.abs(vel.y) < 0.01) vel.y = 0
|
||||
}
|
||||
const keys = Object.keys(updates)
|
||||
if (keys.length > 0) {
|
||||
setCombat(c => {
|
||||
if (!c) return c
|
||||
const chars = { ...c.characters }
|
||||
for (const cid of keys) {
|
||||
if (chars[cid]) chars[cid] = { ...chars[cid], pos: { ...updates[cid] } }
|
||||
}
|
||||
return { ...c, characters: chars }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// TIMER — ticks while timerActive is true, updates all character actions
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -1427,12 +1576,6 @@ class MoveAction {
|
||||
const rawAnim = characterActionsRef.current[cid]
|
||||
if (!rawAnim) continue
|
||||
|
||||
// Sync move action start position from live ref
|
||||
if (rawAnim.type === 'move') {
|
||||
const cp = characterPositionsRef.current[cid]
|
||||
if (cp) { rawAnim.cx = cp.x; rawAnim.cy = cp.y }
|
||||
}
|
||||
|
||||
const done = rawAnim.step(dt)
|
||||
if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) {
|
||||
rawAnim.hitFired = true
|
||||
@@ -1486,15 +1629,14 @@ class MoveAction {
|
||||
// Enemy move → attack transition (skip party members)
|
||||
if (rawAnim.type === 'move' && cid !== playerCharId && !(cc.partyIds ?? []).includes(cid)) {
|
||||
const e = fresh?.characters[cid] ?? cc.characters[cid]
|
||||
const moveSub = rawAnim.actions?.find(a => a instanceof MoveAction)
|
||||
const finalPos = moveSub ? { x: moveSub.toX, y: moveSub.toY } : { x: rawAnim.toX, y: rawAnim.toY }
|
||||
const charPos = characterPositionsRef.current[cid] ?? e?.pos
|
||||
if (e && !isDead(e.integrity)) {
|
||||
const partyIds = cc.partyIds ?? [cc.playerEntityId].filter(Boolean)
|
||||
let targetId = null, minDist = Infinity
|
||||
for (const pid of partyIds) {
|
||||
const pch = (fresh ?? cc).characters[pid]
|
||||
if (!pch || isDead(pch.integrity) || deadThisTickRef.current.has(pid)) continue
|
||||
const d = Math.sqrt((pch.pos.x - finalPos.x) ** 2 + (pch.pos.y - finalPos.y) ** 2)
|
||||
const d = Math.sqrt((pch.pos.x - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2)
|
||||
if (d < minDist) { minDist = d; targetId = pid }
|
||||
}
|
||||
if (targetId) {
|
||||
@@ -1533,6 +1675,9 @@ class MoveAction {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Physics step (integrate velocities, resolve collisions) ──
|
||||
if (cc) { stepPhysics(dt, cc.blocks) }
|
||||
|
||||
// ── Run enemy AI every frame for idle enemies ──
|
||||
if (fresh) {
|
||||
const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries, deadThisTickRef.current)
|
||||
@@ -1554,13 +1699,6 @@ class MoveAction {
|
||||
new MoveAction({
|
||||
...act,
|
||||
blocks: fresh.blocks,
|
||||
setPos: p => {
|
||||
setCombat(c => ({
|
||||
...c,
|
||||
characters: { ...c.characters, [act.charId]: { ...c.characters[act.charId], pos: { ...p } } }
|
||||
}))
|
||||
characterPositionsRef.current[act.charId] = p
|
||||
}
|
||||
})
|
||||
]
|
||||
})
|
||||
@@ -1595,6 +1733,13 @@ class MoveAction {
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [travelAnim])
|
||||
|
||||
// ── Initialize starting location on mount ──
|
||||
useLayoutEffect(() => {
|
||||
if (player.location && !combat) {
|
||||
enterLocation(player.location)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// COMBAT OVER
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -1754,6 +1899,11 @@ class MoveAction {
|
||||
const startFight = (npcList) => {
|
||||
const list = npcList ?? []
|
||||
if (list.length === 0) return
|
||||
// Save current map state before fight
|
||||
if (combat) {
|
||||
savedMapsRef.current[player.location] = saveCurrentMapState()
|
||||
worldItemsRef.current[player.location] = groundItemsRef.current
|
||||
}
|
||||
const enemies = list.map((npc, i) => makeEnemy(npc, i))
|
||||
const characters = {}
|
||||
|
||||
@@ -1800,17 +1950,21 @@ class MoveAction {
|
||||
characters,
|
||||
enemyIds,
|
||||
log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`],
|
||||
blocks: generateLocationBlocks(player.location),
|
||||
blocks: generateLocationBlocks(),
|
||||
}
|
||||
setCombat(initCombat)
|
||||
const locItems = worldItemsRef.current[player.location] ?? []
|
||||
setGroundItems(locItems); groundItemsRef.current = locItems
|
||||
stopTimer(); setBusy(false); prevBusyRef.current = false
|
||||
setCombatTime(0); setDisplayTime(0)
|
||||
characterActionsRef.current = {}
|
||||
characterPositionsRef.current = {}
|
||||
characterVelocitiesRef.current = {}
|
||||
characterDirsRef.current = {}
|
||||
for (const cid of Object.keys(characters)) {
|
||||
characterActionsRef.current[cid] = null
|
||||
characterPositionsRef.current[cid] = { ...characters[cid].pos }
|
||||
characterVelocitiesRef.current[cid] = { x: 0, y: 0 }
|
||||
characterDirsRef.current[cid] = { dx: 0, dy: 1 }
|
||||
}
|
||||
setSelectedEnemy(0); setReplayActive(false); setCurrentActor(null)
|
||||
@@ -1827,6 +1981,20 @@ class MoveAction {
|
||||
/** Enter a location — creates a combat view with all NPCs present as non-hostile */
|
||||
const enterLocation = (locationId, playerData) => {
|
||||
const pd = playerData ?? player
|
||||
|
||||
// Restore saved map if one exists for this location
|
||||
const saved = savedMapsRef.current[locationId]
|
||||
if (saved) {
|
||||
restoreMapState(locationId, saved, pd)
|
||||
const locItems = worldItemsRef.current[locationId] ?? saved.groundItems
|
||||
setGroundItems(locItems); groundItemsRef.current = locItems
|
||||
setCombatTime(0); setDisplayTime(0)
|
||||
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null)
|
||||
speedRef.current = 1; setSpeedMult(1)
|
||||
setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
|
||||
return
|
||||
}
|
||||
|
||||
const characters = {}
|
||||
|
||||
characters['player'] = createCharacter({
|
||||
@@ -1885,29 +2053,35 @@ class MoveAction {
|
||||
characters,
|
||||
enemyIds: [],
|
||||
log: [`You arrive at ${locName}.`],
|
||||
blocks: generateLocationBlocks(locationId),
|
||||
blocks: generateLocationBlocks(),
|
||||
}
|
||||
setCombat(initCombat)
|
||||
const locItems = worldItemsRef.current[locationId] ?? []
|
||||
setGroundItems(locItems); groundItemsRef.current = locItems
|
||||
setCombatTime(0); setDisplayTime(0)
|
||||
characterActionsRef.current = {}
|
||||
characterPositionsRef.current = {}
|
||||
characterVelocitiesRef.current = {}
|
||||
characterDirsRef.current = {}
|
||||
for (const cid of Object.keys(characters)) {
|
||||
characterActionsRef.current[cid] = null
|
||||
characterPositionsRef.current[cid] = { ...characters[cid].pos }
|
||||
characterVelocitiesRef.current[cid] = { x: 0, y: 0 }
|
||||
characterDirsRef.current[cid] = { dx: 0, dy: 1 }
|
||||
}
|
||||
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null)
|
||||
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null); setGroundItems([])
|
||||
speedRef.current = 1; setSpeedMult(1)
|
||||
setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
|
||||
}
|
||||
|
||||
const endFight = () => {
|
||||
if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null }
|
||||
if (combat) worldItemsRef.current[player.location] = groundItemsRef.current
|
||||
stopTimer()
|
||||
setCombatTime(0); setDisplayTime(0)
|
||||
characterActionsRef.current = {}
|
||||
setProjectiles([])
|
||||
characterVelocitiesRef.current = {}
|
||||
setProjectiles([]); setGroundItems([]); groundItemsRef.current = []
|
||||
if (combat) {
|
||||
const deadIds = combat.enemyIds.map(id => combat.characters[id]).filter(e => isDead(e.integrity)).map(e => e.id)
|
||||
if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])])
|
||||
@@ -1943,6 +2117,60 @@ class MoveAction {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save the current map (combat) state for a location */
|
||||
const saveCurrentMapState = () => {
|
||||
if (!combat) return null
|
||||
const cc = combatRef.current ?? combat
|
||||
return {
|
||||
playerEntityId: cc.playerEntityId,
|
||||
partyIds: [...(cc.partyIds ?? [])],
|
||||
npcIds: [...(cc.npcIds ?? [])],
|
||||
enemyIds: [...(cc.enemyIds ?? [])],
|
||||
characters: JSON.parse(JSON.stringify(cc.characters)),
|
||||
log: [...cc.log],
|
||||
blocks: cc.blocks,
|
||||
groundItems: [...(groundItemsRef.current ?? [])],
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a saved map state, updating player with current data */
|
||||
const restoreMapState = (locationId, saved, playerData) => {
|
||||
const pd = playerData ?? player
|
||||
const savedPlayer = saved.characters['player']
|
||||
saved.characters['player'] = createCharacter({
|
||||
id: 'player', name: pd.name,
|
||||
pos: savedPlayer?.pos ?? { x: 20, y: 75 },
|
||||
integrity: { ...pd.integrity },
|
||||
injuries: JSON.parse(JSON.stringify(pd.injuries)),
|
||||
equipped: { ...pd.equipped },
|
||||
weaponConditions: { ...pd.weaponConditions },
|
||||
weaponMagazines: { ...pd.weaponMagazines },
|
||||
itemCounts: { ...pd.itemCounts },
|
||||
})
|
||||
setCombat({
|
||||
playerEntityId: saved.playerEntityId,
|
||||
partyIds: saved.partyIds,
|
||||
npcIds: saved.npcIds,
|
||||
enemyIds: saved.enemyIds,
|
||||
characters: saved.characters,
|
||||
log: [`You arrive at ${locMap[locationId]?.name ?? locationId}.`],
|
||||
blocks: saved.blocks,
|
||||
})
|
||||
setGroundItems(saved.groundItems)
|
||||
groundItemsRef.current = saved.groundItems
|
||||
setCombatTime(0); setDisplayTime(0)
|
||||
characterPositionsRef.current = {}
|
||||
characterVelocitiesRef.current = {}
|
||||
characterDirsRef.current = {}
|
||||
characterActionsRef.current = {}
|
||||
for (const cid of Object.keys(saved.characters)) {
|
||||
characterPositionsRef.current[cid] = { ...saved.characters[cid].pos }
|
||||
characterVelocitiesRef.current[cid] = { x: 0, y: 0 }
|
||||
characterDirsRef.current[cid] = { dx: 0, dy: 1 }
|
||||
characterActionsRef.current[cid] = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute a player attack; queue it for execution. */
|
||||
const executeAttack = () => {
|
||||
if (!limbSelect || !selectedTarget) {
|
||||
@@ -1987,12 +2215,12 @@ class MoveAction {
|
||||
if (action.type === 'shove_pending') {
|
||||
const enemy2 = cc.characters[action.enemyCharId]
|
||||
if (!enemy2 || isDead(enemy2.integrity)) return
|
||||
const { pos, actualPush } = computeShove(actorPos, enemy2.pos, fresh.blocks)
|
||||
setCombat(c => ({
|
||||
...c,
|
||||
characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } },
|
||||
log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`],
|
||||
}))
|
||||
const dx = enemy2.pos.x - actorPos.x
|
||||
const dy = enemy2.pos.y - actorPos.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const pushSpeed = 60 + Math.random() * 20
|
||||
characterVelocitiesRef.current[action.enemyCharId] = { x: (dx / dist) * pushSpeed, y: (dy / dist) * pushSpeed }
|
||||
setCombat(c => ({ ...c, log: [...c.log, `${actorChar.name} shoves ${enemy2.name}!`] }))
|
||||
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
|
||||
return
|
||||
}
|
||||
@@ -2068,13 +2296,7 @@ class MoveAction {
|
||||
const maxSpd = ch.maxSpeed ?? 10
|
||||
const pct = moveMode === 'run' ? 1.0 : 0.5
|
||||
const speed = maxSpd * pct
|
||||
const moveAction = new MoveAction({ type: 'move', fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks,
|
||||
setPos: (p, cid) => {
|
||||
const id = cid || actorId
|
||||
setCombat(c => ({ ...c, characters: { ...c.characters, [id]: { ...c.characters[id], pos: { ...p } } } }))
|
||||
characterPositionsRef.current[id] = p
|
||||
}
|
||||
})
|
||||
const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks })
|
||||
const dx = targetX - curPos.x, dy = targetY - curPos.y
|
||||
const d = Math.sqrt(dx * dx + dy * dy)
|
||||
const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 }
|
||||
@@ -2275,6 +2497,11 @@ class MoveAction {
|
||||
|
||||
const travel = (targetId) => {
|
||||
if (travelAnim) return
|
||||
// Save current map state before leaving
|
||||
if (combat) {
|
||||
savedMapsRef.current[player.location] = saveCurrentMapState()
|
||||
worldItemsRef.current[player.location] = groundItemsRef.current
|
||||
}
|
||||
const from = locations.find(l => l.id === player.location)
|
||||
const to = locations.find(l => l.id === targetId)
|
||||
if (!from || !to) return
|
||||
@@ -2303,13 +2530,84 @@ class MoveAction {
|
||||
setEquipArmChoice(null)
|
||||
}
|
||||
|
||||
const dropWeapon = (weaponId, actorId) => {
|
||||
const isPlayer = actorId === 'player'
|
||||
if (isPlayer) {
|
||||
const p = playerRef.current
|
||||
const slots = []
|
||||
if (p.equipped.left === weaponId) slots.push('left')
|
||||
if (p.equipped.right === weaponId) slots.push('right')
|
||||
if (slots.length === 0) return
|
||||
const newEquipped = { ...p.equipped }
|
||||
for (const s of slots) newEquipped[s] = 'fists'
|
||||
setPlayer(p => ({ ...p, equipped: newEquipped }))
|
||||
setCombat(prev => {
|
||||
const ch = prev.characters['player']
|
||||
if (!ch) return prev
|
||||
const ce = { ...ch.equipped }
|
||||
for (const s of slots) ce[s] = 'fists'
|
||||
return { ...prev, characters: { ...prev.characters, 'player': { ...ch, equipped: ce } } }
|
||||
})
|
||||
} else {
|
||||
setCombat(prev => {
|
||||
const ch = prev.characters[actorId]
|
||||
if (!ch) return prev
|
||||
const equipped = { ...ch.equipped }
|
||||
const slots = []
|
||||
if (equipped.left === weaponId) slots.push('left')
|
||||
if (equipped.right === weaponId) slots.push('right')
|
||||
if (slots.length === 0) return prev
|
||||
for (const s of slots) equipped[s] = 'fists'
|
||||
return { ...prev, characters: { ...prev.characters, [actorId]: { ...ch, equipped } } }
|
||||
})
|
||||
}
|
||||
const cc = combatRef.current
|
||||
const charPos = isPlayer
|
||||
? characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos ?? { x: 80, y: 80 }
|
||||
: cc?.characters[actorId]?.pos ?? { x: 80, y: 80 }
|
||||
const gi = { id: `gi_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, itemId: weaponId, x: charPos.x, y: charPos.y }
|
||||
setGroundItems(prev => { const next = [...prev, gi]; groundItemsRef.current = next; return next })
|
||||
}
|
||||
|
||||
const pickupGroundItem = (groundId) => {
|
||||
const gi = groundItemsRef.current.find(g => g.id === groundId)
|
||||
if (!gi) return
|
||||
const cc = combatRef.current
|
||||
const playerPos = characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos
|
||||
if (!playerPos) return
|
||||
const dx = playerPos.x - gi.x, dy = playerPos.y - gi.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist > 15) {
|
||||
setCombat(prev => ({ ...prev, log: [...prev.log, 'Too far to pick up.'] }))
|
||||
return
|
||||
}
|
||||
const itemWpn = weaponMap[gi.itemId]
|
||||
if (itemWpn) {
|
||||
setPlayer(p => ({ ...p, inventory: [...p.inventory, gi.itemId], weaponConditions: p.weaponConditions[gi.itemId] !== undefined ? p.weaponConditions : { ...p.weaponConditions, [gi.itemId]: itemWpn.maxCondition } }))
|
||||
setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: [...(prev.characters['player']?.inventory ?? []), gi.itemId] } }, log: [...prev.log, `Picked up ${itemWpn.name ?? gi.itemId}.`] }))
|
||||
} else {
|
||||
setPlayer(p => ({ ...p, inventory: [...p.inventory, gi.itemId] }))
|
||||
setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: [...(prev.characters['player']?.inventory ?? []), gi.itemId] } }, log: [...prev.log, `Picked up ${itemMap[gi.itemId]?.name ?? gi.itemId}.`] }))
|
||||
}
|
||||
setGroundItems(prev => { const next = prev.filter(g => g.id !== groundId); groundItemsRef.current = next; return next })
|
||||
}
|
||||
|
||||
const dropInventoryItem = (itemId) => {
|
||||
const p = playerRef.current
|
||||
const idx = p.inventory.indexOf(itemId)
|
||||
if (idx === -1) return
|
||||
const cc = combatRef.current
|
||||
const pos = characterPositionsRef.current['player'] ?? cc?.characters['player']?.pos ?? { x: 80, y: 80 }
|
||||
const gi = { id: `gi_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, itemId, x: pos.x, y: pos.y }
|
||||
setGroundItems(prev => { const next = [...prev, gi]; groundItemsRef.current = next; return next })
|
||||
setPlayer(p => ({ ...p, inventory: p.inventory.filter(id => id !== itemId) }))
|
||||
setCombat(prev => ({ ...prev, characters: { ...prev.characters, 'player': { ...prev.characters['player'], inventory: (prev.characters['player']?.inventory ?? []).filter(id => id !== itemId) } }, log: [...prev.log, `Dropped ${weaponMap[itemId]?.name ?? itemMap[itemId]?.name ?? itemId}.`] }))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DISPLAY HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' }
|
||||
const selectLimbTarget = (part) => setSelectedTarget(partMap[part] || part)
|
||||
|
||||
/** Render inline skill → enemy → limb → confirm dropdown inside the action tree */
|
||||
const renderSkillDropdown = (weaponId, actorId) => {
|
||||
const weapon = weaponMap[weaponId]
|
||||
@@ -2322,7 +2620,7 @@ class MoveAction {
|
||||
<div className="tree-children">
|
||||
{skills.map((skill, si) => {
|
||||
const active = selectedSkill === skill.name
|
||||
const isSpecial = skill.name === 'Load' || skill.name === 'Unload'
|
||||
const isSpecial = skill.name === 'Load' || skill.name === 'Unload' || skill.name === 'Drop'
|
||||
return (
|
||||
<Fragment key={si}>
|
||||
{active && isSpecial ? (
|
||||
@@ -2330,6 +2628,7 @@ class MoveAction {
|
||||
onClick={() => {
|
||||
if (skill.name === 'Load') { startReload(); return }
|
||||
if (skill.name === 'Unload') { unloadPistol(); return }
|
||||
if (skill.name === 'Drop') { dropWeapon(weaponId, actorId); return }
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSkill(skill.name)}
|
||||
onMouseLeave={() => setHoveredSkill(null)}>
|
||||
@@ -2350,7 +2649,7 @@ class MoveAction {
|
||||
) : (
|
||||
<div className="tree-node leaf"
|
||||
onClick={() => {
|
||||
if (isSpecial) { if (skill.name === 'Load') { startReload(); return }; if (skill.name === 'Unload') { unloadPistol(); return } }
|
||||
if (isSpecial) { if (skill.name === 'Load') { startReload(); return }; if (skill.name === 'Unload') { unloadPistol(); return }; if (skill.name === 'Drop') { dropWeapon(weaponId, actorId); return } }
|
||||
setSelectedSkill(skill.name); setLimbSelect(weaponId); setCurrentActor(actorId)
|
||||
setAttackEnemy(null); setSelectedTarget(null)
|
||||
setAimMode(true)
|
||||
@@ -2370,14 +2669,30 @@ class MoveAction {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{[{ key: 'torso', label: 'Torso' }, { key: 'head', label: 'Head' }, { key: 'larm', label: 'Left Arm' }, { key: 'rarm', label: 'Right Arm' }, { key: 'lleg', label: 'Left Leg' }, { key: 'rleg', label: 'Right Leg' }].map(({ key, label }) => (
|
||||
<div key={key}
|
||||
className={'tree-node leaf' + (selectedTarget === (partMap[key] || key) ? ' tree-leaf-selected' : '')}
|
||||
onClick={() => selectLimbTarget(key)}>
|
||||
<span className="tree-bullet">○</span>
|
||||
<span className="tree-label">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const renderLimbTree = (tree, depth) =>
|
||||
Object.entries(tree).map(([key, children]) => {
|
||||
const hasChildren = Object.keys(children).length > 0
|
||||
const isSelected = selectedTarget === key
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<div key={key}
|
||||
className={'tree-node' + (hasChildren ? ' folder' : ' leaf') + (isSelected ? (hasChildren ? ' tree-folder-selected' : ' tree-leaf-selected') : '')}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
onClick={() => setSelectedTarget(key)}>
|
||||
{hasChildren ? <span className="tree-toggle">▼</span> : <span className="tree-bullet">○</span>}
|
||||
<span className="tree-label">{bodyPartLabels[key] || key}</span>
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="tree-children">
|
||||
{renderLimbTree(children, depth)}
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
return renderLimbTree(BODY_TREE, 0)
|
||||
})()}
|
||||
<div className="tree-node leaf" style={{ cursor: 'default', padding: '6px 8px', gap: 8 }}>
|
||||
<button className="limb-confirm-btn" onClick={executeAttack}
|
||||
disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || (ac ? isDead(ac.integrity) : false)}
|
||||
@@ -2888,7 +3203,7 @@ class MoveAction {
|
||||
<circle key={s.key} cx={bx + s.ox} cy={by + s.oy} r="3" fill={s.fill} stroke="#000" strokeWidth="0.5" />
|
||||
)
|
||||
const isFist = w => w === 'fists' || w === 'left_arm' || w === 'right_arm'
|
||||
const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK].includes(t)
|
||||
const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK, ANIM_TYPES.OVERHEAD].includes(t)
|
||||
const allSlots = []
|
||||
const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
|
||||
partyIds.forEach(cid => {
|
||||
@@ -2937,7 +3252,7 @@ class MoveAction {
|
||||
const bx = shake ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x
|
||||
const by = shake ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y
|
||||
const isFist = wpn === 'fists' || wpn === 'left_arm' || wpn === 'right_arm'
|
||||
const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK))
|
||||
const enemyAnim = animations.find(a => a.charId === eid && (a.type === ANIM_TYPES.SLASH || a.type === ANIM_TYPES.PIERCE_STRIKE || a.type === ANIM_TYPES.FIST_STRIKE || a.type === ANIM_TYPES.FIST_HOOK || a.type === ANIM_TYPES.OVERHEAD))
|
||||
if (isFist && !enemyAnim) return null
|
||||
const p = enemyAnim ? { x: getAnimPos(enemyAnim).x - enemy.pos.x, y: getAnimPos(enemyAnim).y - enemy.pos.y } : { x: -dir.dy * 7, y: dir.dx * 7 }
|
||||
return <circle key={`ew-${eid}`} cx={bx + p.x} cy={by + p.y} r="3" fill={isFist ? enemy.fillColor : '#f97316'} stroke="#000" strokeWidth="0.5" />
|
||||
@@ -2946,6 +3261,12 @@ class MoveAction {
|
||||
{projectiles.map(p => (
|
||||
<circle key={p.id} cx={p.pos.x} cy={p.pos.y} r="1.5" fill="#fbbf24" stroke="#ff6b35" strokeWidth="1" />
|
||||
))}
|
||||
{/* Ground items */}
|
||||
{groundItems.map(gi => (
|
||||
<circle key={gi.id} cx={gi.x} cy={gi.y} r="2.5" fill="#a78bfa" stroke="#7c3aed" strokeWidth="1"
|
||||
style={{ cursor: 'pointer', pointerEvents: 'auto' }}
|
||||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, groundId: gi.id, itemId: gi.itemId }) }} />
|
||||
))}
|
||||
{/* Direction arrows (on top, in front of character) */}
|
||||
{combat && (() => {
|
||||
const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean)
|
||||
@@ -3167,7 +3488,52 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="inv-total-row"><span>Integrity</span><span>{charHp}/{MAX_HP}</span></div>
|
||||
<div className="inv-parts-title" style={{ marginTop: 12 }}>Capabilities</div>
|
||||
{(() => {
|
||||
const i = character.integrity
|
||||
const cap = (val) => Math.max(0, Math.min(100, Math.round(val * 100)))
|
||||
const consciousness = cap(i.head / MAX_BODY.head)
|
||||
const sight = cap(i.head / MAX_BODY.head)
|
||||
const hearing = cap(i.head / MAX_BODY.head)
|
||||
const manipulation = cap((i.leftHand / MAX_BODY.leftHand + i.leftFingers / MAX_BODY.leftFingers + i.rightHand / MAX_BODY.rightHand + i.rightFingers / MAX_BODY.rightFingers) / 4)
|
||||
const movement = cap((i.leftUpperLeg / MAX_BODY.leftUpperLeg + i.leftLowerLeg / MAX_BODY.leftLowerLeg + i.leftFoot / MAX_BODY.leftFoot + i.leftToes / MAX_BODY.leftToes + i.rightUpperLeg / MAX_BODY.rightUpperLeg + i.rightLowerLeg / MAX_BODY.rightLowerLeg + i.rightFoot / MAX_BODY.rightFoot + i.rightToes / MAX_BODY.rightToes) / 8)
|
||||
const caps = [
|
||||
{ label: 'Consciousness', val: consciousness, parts: [{ part: 'head' }] },
|
||||
{ label: 'Sight', val: sight, parts: [{ part: 'head' }] },
|
||||
{ label: 'Hearing', val: hearing, parts: [{ part: 'head' }] },
|
||||
{ label: 'Manipulation', val: manipulation, parts: [{ part: 'leftHand' }, { part: 'leftFingers' }, { part: 'rightHand' }, { part: 'rightFingers' }] },
|
||||
{ label: 'Movement', val: movement, parts: [{ part: 'leftUpperLeg' }, { part: 'leftLowerLeg' }, { part: 'leftFoot' }, { part: 'leftToes' }, { part: 'rightUpperLeg' }, { part: 'rightLowerLeg' }, { part: 'rightFoot' }, { part: 'rightToes' }] },
|
||||
]
|
||||
return caps.map(c => (
|
||||
<div key={c.label}
|
||||
onMouseEnter={() => setHoveredCap(c.label)}
|
||||
onMouseLeave={() => setHoveredCap(null)}
|
||||
style={{ position: 'relative' }}>
|
||||
<div className="part-row">
|
||||
<div className="part-color" style={{ background: partColor(c.val, 100) }} />
|
||||
<div className="part-label">{c.label}</div>
|
||||
<div className="part-bar-track"><div className="part-bar-fill" style={{ width: `${c.val}%`, background: partColor(c.val, 100) }} /></div>
|
||||
<div className="part-hp">{c.val}%</div>
|
||||
</div>
|
||||
{hoveredCap === c.label && (
|
||||
<div className="cap-breakdown">
|
||||
{c.parts.map(p => {
|
||||
const hp = i[p.part] ?? 0
|
||||
const max = MAX_BODY[p.part]
|
||||
return (
|
||||
<div key={p.part} className="cap-breakdown-row">
|
||||
<div className="cap-bd-color" style={{ background: partColor(hp, max) }} />
|
||||
<span className="cap-bd-label">{bodyPartLabels[p.part] || p.part}</span>
|
||||
<span className="cap-bd-bar"><span className="cap-bd-fill" style={{ width: `${(hp / max) * 100}%`, background: partColor(hp, max) }} /></span>
|
||||
<span className="cap-bd-hp">{hp}/{max}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{character && invTab === 'inventory' && (
|
||||
@@ -3230,6 +3596,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
|
||||
? <span style={{ marginLeft: 6, color: '#9ca3af' }}>×{qty}</span>
|
||||
: itemMap[itemId] && <span style={{ marginLeft: 6, color: '#9ca3af' }}>×1</span>}
|
||||
{isPlayer && weaponMap[itemId] && <button className="equip-btn" onClick={() => setEquipArmChoice(itemId)}>Equip</button>}
|
||||
{isPlayer && <button className="drop-btn" style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px', background: '#4a3030', border: '1px solid #6b4040', color: '#e0a0a0', borderRadius: 2, cursor: 'pointer' }} onClick={() => dropInventoryItem(itemId)}>Drop</button>}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3527,8 +3894,14 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
|
||||
{contextMenu && (
|
||||
<div className="backdrop" style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setContextMenu(null)} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
|
||||
<div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y, position: 'absolute' }} onClick={e => { e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
|
||||
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(contextMenu.charId); setContextMenu(null) }}>Inspect</button>
|
||||
<button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button>
|
||||
{contextMenu.groundId ? (
|
||||
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); pickupGroundItem(contextMenu.groundId); setContextMenu(null) }}>Pick Up {weaponMap[contextMenu.itemId]?.name ?? itemMap[contextMenu.itemId]?.name ?? contextMenu.itemId}</button>
|
||||
) : (
|
||||
<>
|
||||
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(contextMenu.charId); setContextMenu(null) }}>Inspect</button>
|
||||
<button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user