From 7f0b7885b019c8041f7798d0ff9e0d95c44daf41 Mon Sep 17 00:00:00 2001 From: johnruina Date: Wed, 24 Jun 2026 23:00:25 -0400 Subject: [PATCH] bugger --- src/App.css | 47 +- src/App.jsx | 1158 +++++++++++++++++++++++++++++++++--------------- src/names.json | 47 ++ 3 files changed, 870 insertions(+), 382 deletions(-) create mode 100644 src/names.json diff --git a/src/App.css b/src/App.css index 8fe2f85..b5717b7 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,17 @@ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: #1a1a2e; +} +::-webkit-scrollbar-thumb { + background: #3a3a5a; + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: #5F71C5; +} + * { margin: 0; padding: 0; @@ -1199,31 +1213,6 @@ body { border-color: #5F71C5; } -.bottom-left-btns { - position: absolute; - bottom: 40px; - left: 40px; - display: flex; - gap: 8px; - z-index: 10; -} - -.fight-rand-btn { - background: #1e1e30; - border: 1px solid #ef4444; - border-radius: 1px; - color: #ef4444; - font-size: 14px; - padding: 10px 18px; - cursor: pointer; - user-select: none; - font-weight: bold; -} - -.fight-rand-btn:hover { - background: #2a1a1a; -} - .info-panel { position: absolute; bottom: 40px; @@ -1516,8 +1505,7 @@ body { padding: 24px 32px 32px; width: 1200px; max-width: 95vw; - min-height: 70vh; - max-height: 95vh; + height: 85vh; position: relative; overflow-y: auto; } @@ -2307,6 +2295,7 @@ body { justify-content: center; background: #0f0f1a; background-image: radial-gradient(ellipse at 50% 30%, #1a1a3a 0%, #0f0f1a 70%); + overflow: hidden; } .menu-content { @@ -2356,7 +2345,9 @@ body { display: flex; flex-direction: column; gap: 20px; - min-width: 400px; + width: 480px; + height: 560px; + overflow-y: auto; } .char-create-title { diff --git a/src/App.jsx b/src/App.jsx index 37ffaab..b7453bf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,37 +3,104 @@ import './App.css' import maleBody from './assets/body/malebody.png' import pistolShotMp3 from './assets/pistolshot.mp3' import chamberBulletMp3 from './assets/chamberbullet.mp3' +import names from './names.json' + +function pickRandomName(gender) { + const list = gender === 'male' ? names.male_names : gender === 'female' ? names.female_names : names.neutral_names + return list[Math.floor(Math.random() * list.length)] +} // ───────────────────────────────────────────────────────────────────────────── // WORLD DATA // ───────────────────────────────────────────────────────────────────────────── -const locations = [ - { id: 'A', name: 'Alpha', description: 'A quiet village at the edge of the forest.', x: 200, y: 300 }, - { id: 'B', name: 'Beta', description: 'A bustling trade port by the sea.', x: 600, y: 300 }, - { id: 'C', name: 'Gamma', description: 'A hidden monastery in the mountains.', x: 400, y: 130 }, - { id: 'D', name: 'Delta', description: 'An ancient ruined city swallowed by desert.', x: 100, y: 130 }, - { id: 'E', name: 'Epsilon', description: 'A floating island held aloft by magic.', x: 700, y: 130 }, -] +function generateWorld() { + const hexR = 90, sqrt3 = Math.sqrt(3) + const centerX = 400, centerY = 300 + const names = ['Northpoint', 'Eastwatch', 'Southhaven', 'Westmark', 'Highpeak', 'Lowdale', 'Place'] + const descs = [ + 'A windswept ridge overlooking the surrounding lands.', + 'A bustling market town on the eastern river.', + 'A quiet fishing village on the southern coast.', + 'An ancient fort nestled in the western hills.', + 'A monastery perched high on the mountainside.', + 'A fertile valley with scattered farms and orchards.', + 'A central hub where all paths meet.', + ] -const connections = [ - { from: 'A', to: 'B' }, { from: 'A', to: 'C' }, { from: 'A', to: 'D' }, - { from: 'B', to: 'C' }, { from: 'B', to: 'E' }, - { from: 'C', to: 'D' }, { from: 'C', to: 'E' }, -] + // Axial coordinates: ring of 6 + center (flat-top orientation) + const axial = [ + { q: 1, r: 0 }, // 0: right + { q: 0, r: 1 }, // 1: bottom-right + { q: -1, r: 1 }, // 2: bottom-left + { q: -1, r: 0 }, // 3: left + { q: 0, r: -1 }, // 4: top-left + { q: 1, r: -1 }, // 5: top-right + { q: 0, r: 0 }, // 6: center + ] -const characters = [ - { id: 'player', name: 'John', location: 'A', size: 1 }, - { id: '1', name: 'Mara', location: 'A', money: 120, inventory: ['pistol_ammo'], size: 1 }, - { id: '2', name: 'Rook', location: 'A', money: 0, inventory: ['pistol_ammo'], size: 1 }, - { id: '3', name: 'Vex', location: 'B', money: 500, inventory: ['pistol_ammo'], size: 1 }, - { id: 'd1', name: 'Dust Gutter', location: 'D', money: 15, inventory: ['pistol_ammo'], size: 1 }, - { id: 'd2', name: 'Bone Collector', location: 'D', money: 30, inventory: ['pistol_ammo'], size: 1 }, - { id: 'd3', name: 'Sand Viper', location: 'D', money: 8, inventory: ['pistol_ammo'], size: 1 }, - { id: 'd4', name: 'Rust-Eye', location: 'D', money: 45, inventory: ['pistol_ammo'], size: 1 }, - { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, - { id: 'e1', name: 'Zephyr', location: 'E', money: 9999, inventory: ['pistol', 'pistol', 'steel_sword', 'iron_shield', 'dagger', 'warhammer', 'rusty_sword', 'pistol_ammo', 'bouncing_ammo'], itemCounts: { pistol_ammo: 20, bouncing_ammo: 5 }, size: 1 }, -] + const locations = [] + for (let i = 0; i < 7; i++) { + const { q, r } = axial[i] + const x = Math.round(centerX + hexR * 3 / 2 * q) + const y = Math.round(centerY + hexR * (sqrt3 / 2 * q + sqrt3 * r)) + locations.push({ id: `loc${i}`, name: names[i], description: descs[i], x, y }) + } + + const connections = [] + for (let i = 0; i < 6; i++) { + connections.push({ from: `loc${i}`, to: `loc${(i + 1) % 6}` }) + connections.push({ from: `loc6`, to: `loc${i}` }) + } + + // Assign special NPCs to random locations + const shuffled = [0, 1, 2, 3, 4, 5, 6].sort(() => Math.random() - 0.5) + const zephyrIdx = shuffled[0] + const fightIdx = shuffled[1] + + const characters = [] + + // Zephyr (rich merchant with top-tier gear) + characters.push({ + id: 'zephyr', name: 'Zephyr', location: `loc${zephyrIdx}`, money: 9999, + inventory: ['pistol', 'pistol', 'steel_sword', 'iron_shield', 'dagger', 'warhammer', 'rusty_sword', 'pistol_ammo', 'bouncing_ammo'], + itemCounts: { pistol_ammo: 20, bouncing_ammo: 5 }, size: 1, + }) + + // Fighting NPCs (Marcus with rusty_sword vs Viktor with dagger) + characters.push({ + id: 'b1', name: 'Marcus', location: `loc${fightIdx}`, money: 100, + inventory: ['rusty_sword'], equipped: { left: 'rusty_sword', right: 'fists' }, + relationships: { b2: 'enemy' }, size: 1, + }) + characters.push({ + id: 'b2', name: 'Viktor', location: `loc${fightIdx}`, money: 80, + inventory: ['dagger'], equipped: { left: 'dagger', right: 'fists' }, + relationships: { b1: 'enemy' }, size: 1, + }) + + // Generic NPCs for the remaining locations + let npcId = 0 + for (let i = 0; i < 7; i++) { + if (i === zephyrIdx || i === fightIdx) continue + characters.push({ + id: `npc${npcId}`, location: `loc${i}`, + money: 50 + Math.floor(Math.random() * 200), + inventory: ['pistol_ammo'], size: 1, + }) + npcId++ + } + + // Assign random fill colors to any character that doesn't have one + for (const ch of characters) { + if (!ch.fillColor) ch.fillColor = `hsl(${Math.random() * 360}, 70%, 55%)` + } + + return { locations, connections, characters } +} + +const { locations, connections, characters } = generateWorld() +const startLocId = locations[Math.floor(Math.random() * locations.length)].id 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 }] }, @@ -203,6 +270,25 @@ 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 } +function getConsciousness(integrity) { + if (!integrity) return 0 + return Math.max(0, Math.min(100, Math.round((integrity.head ?? 0) / MAX_BODY.head * 100))) +} +function isUnconscious(integrity) { return !isDead(integrity) && getConsciousness(integrity) < 10 } +function hexPoints(cx, cy, r) { + const pts = [] + for (let i = 0; i < 6; i++) { + const a = Math.PI / 3 * i + pts.push(`${Math.round((cx + r * Math.cos(a)) * 10) / 10},${Math.round((cy + r * Math.sin(a)) * 10) / 10}`) + } + return pts.join(' ') +} +function getMovement(integrity) { + if (!integrity) return 0 + const parts = ['leftUpperLeg','leftLowerLeg','leftFoot','leftToes','rightUpperLeg','rightLowerLeg','rightFoot','rightToes'] + const avg = parts.reduce((s, p) => s + (integrity[p] ?? 0) / MAX_BODY[p], 0) / parts.length + return Math.max(0, Math.min(100, Math.round(avg * 100))) +} function propagateDestruction(integrity) { const out = { ...integrity } for (const part of ALL_PARTS) { @@ -215,14 +301,36 @@ function propagateDestruction(integrity) { return out } +function setRelationship(setCombat, cc, actorId, targetId, type) { + if (!cc || !cc.characters) return + const chars = { ...cc.characters } + const actor = chars[actorId] + const target = chars[targetId] + if (!actor || !target) return + chars[actorId] = { ...actor, relationships: { ...actor.relationships, [targetId]: type } } + chars[targetId] = { ...target, relationships: { ...target.relationships, [actorId]: type } } + cc.characters = chars + setCombat(prev => ({ ...prev, characters: chars })) +} + +function getRelationships(char, characters) { + if (!char || !char.relationships) return {} + const result = {} + for (const [id, type] of Object.entries(char.relationships)) { + const other = characters[id] + result[id] = { type, name: other?.name ?? id } + } + return result +} + function createCharacter(data) { - const weaponId = data.weapon ?? 'fists' + const weaponId = data.weapon ?? data.equipped?.left ?? 'fists' const wpn = weaponMap[weaponId] const integrity = data.integrity ?? freshBody() const injuries = data.injuries ?? freshInjuries() return { id: data.id ?? 'unknown', - name: data.name ?? 'Unknown', + name: data.name ?? pickRandomName(data.gender), age: data.age ?? Math.floor(Math.random() * 30) + 18, location: data.location ?? 'Unknown', money: data.money ?? 0, @@ -241,6 +349,9 @@ function createCharacter(data) { attack: data.attack ?? wpn?.damage ?? 30, weaponMagazines: data.weaponMagazines ? { ...data.weaponMagazines } : {}, itemCounts: data.itemCounts ? { ...data.itemCounts } : {}, + relationships: data.relationships ? { ...data.relationships } : {}, + gender: data.gender ?? 'neutral', + owner: data.owner ?? null, } } @@ -259,12 +370,12 @@ function applyInjury(injuries, part, type, severity) { } function calcIntegrity(integrity, injuries, deltaTime = 1) { - let drain = 0 - for (const part of Object.keys(injuries)) { - for (const inj of injuries[part]) drain += (INJURY_DECAY[inj.type] ?? 0.3) * inj.severity - } const out = {} - for (const part of Object.keys(integrity)) out[part] = Math.max(0, integrity[part] - drain * deltaTime) + for (const part of Object.keys(integrity)) { + let partDrain = 0 + for (const inj of (injuries[part] ?? [])) partDrain += (INJURY_DECAY[inj.type] ?? 0.3) * inj.severity + out[part] = Math.max(0, integrity[part] - partDrain * deltaTime) + } return propagateDestruction(out) } @@ -369,7 +480,7 @@ function canReach(attackerPos, targetPos, weaponId, skillName) { * Build a player attack action for the action queue. * Returns an action object that actionHandlers.attack can process. */ -function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player', targetCharId }) { +function buildPlayerAttack({ weaponId, skillName, targetPart, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player', targetCharId }) { const base = weaponMap[weaponId] const isArm = weaponId === 'left_arm' || weaponId === 'right_arm' const skill = isArm @@ -377,13 +488,13 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp : (base?.skills ?? []).find(s => s.name === skillName) ?? { name: 'Attack', mult: 1.0, damageType: 'blunt' } if (skill.damageType === 'shove') { - return { type: 'shove_pending', actor, weaponId, skillName, enemyIndex, charId } + return { type: 'shove_pending', actor, weaponId, skillName, charId } } const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) - const enemyId = combat.enemyIds[enemyIndex] - const enemy = targetCharId ? combat.characters[targetCharId] : (enemyId ? combat.characters[enemyId] : null) + const enemyId = targetCharId + const enemy = enemyId ? combat.characters[enemyId] : null return { type: 'attack', @@ -391,7 +502,7 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp charId, enemyCharId: targetCharId || enemyId, targetCharId, - weaponId, skillName, targetPart, enemyIndex, + weaponId, skillName, targetPart, dmg, severity, injType, injLabel, attackerParts: { ...bodyParts }, attackerInjuries: { ...bodyInjuries }, @@ -406,37 +517,34 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp */ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { const actions = [] - const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) - const aliveParty = partyIds.filter(pid => { - const ch = combat.characters[pid] - return ch && !isDead(ch.integrity) && !(deadThisTick?.has(pid)) - }) - if (aliveParty.length === 0) return actions for (const cid of Object.keys(combat.characters)) { - if (partyIds.includes(cid) || (combat.npcIds ?? []).includes(cid)) continue - const e = combat.characters[cid] - if (isDead(e.integrity)) continue + const char = combat.characters[cid] + if (!char || char.owner === 'player') continue + if (isDead(char.integrity) || isUnconscious(char.integrity)) continue - // Find nearest alive party member for this enemy + // Purely relationship-driven: find enemy relationships on the same map let targetId = null, targetPos = null, minDist = Infinity - for (const pid of aliveParty) { - const pch = combat.characters[pid] - const d = Math.sqrt((pch.pos.x - e.pos.x) ** 2 + (pch.pos.y - e.pos.y) ** 2) - if (d < minDist) { minDist = d; targetId = pid; targetPos = pch.pos } + if (char.relationships) { + for (const [targetCid, rel] of Object.entries(char.relationships)) { + if (rel !== 'enemy') continue + const target = combat.characters[targetCid] + if (!target || isDead(target.integrity) || deadThisTick?.has(targetCid)) continue + const d = Math.sqrt((target.pos.x - char.pos.x) ** 2 + (target.pos.y - char.pos.y) ** 2) + if (d < minDist) { minDist = d; targetId = targetCid; targetPos = target.pos } + } } if (!targetId) continue const targetChar = combat.characters[targetId] // ── in range → attack - const weaponId = e.weapon ?? 'fists' + const weaponId = char.weapon ?? 'fists' const base = weaponMap[weaponId] const skills = base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }] const maxRange = Math.max(...skills.map(s => s.range ?? 15)) if (minDist > maxRange) { // out of range → move toward target - const speed = e.speed > 12 ? 10 : 5 - actions.push({ type: 'move', charId: cid, fromX: e.pos.x, fromY: e.pos.y, toX: targetPos.x, toY: targetPos.y, speed }) + actions.push({ type: 'move', charId: cid, fromX: char.pos.x, fromY: char.pos.y, toX: targetPos.x, toY: targetPos.y }) continue } const skill = skills[Math.floor(Math.random() * skills.length)] @@ -451,8 +559,8 @@ function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { weaponId, skillName: skill.name, dmg, severity, injType, injLabel, - attackerParts: { ...e.integrity }, - attackerInjuries: { ...e.injuries }, + attackerParts: { ...char.integrity }, + attackerInjuries: { ...char.injuries }, targetParts: { ...targetChar.integrity }, targetInjuries: { ...targetChar.injuries }, }) @@ -500,9 +608,21 @@ function BodyImage({ body }) { function CharacterCreator({ onConfirm }) { const [name, setName] = useState('John') + const [gender, setGender] = useState('male') const [weaponId, setWeaponId] = useState('rusty_sword') const [fillColor, setFillColor] = useState('#fbbf24') - const [locationId, setLocationId] = useState('A') + const [locationId, setLocationId] = useState(startLocId) + const [debugAbilities, setDebugAbilities] = useState([]) + + const handleGenderChange = (g) => { + setGender(g) + if (name === 'John' && g === 'female') setName('Jane') + else if (name === 'Jane' && g === 'male') setName('John') + else if (name === 'Jane' && g === 'neutral') setName('Jordan') + else if (name === 'John' && g === 'neutral') setName('Jordan') + else if (name === 'Jordan' && g === 'female') setName('Jane') + else if (name === 'Jordan' && g === 'male') setName('John') + } const startWeapons = [ { id: 'rusty_sword', name: 'Rusty Sword' }, @@ -523,6 +643,16 @@ function CharacterCreator({ onConfirm }) { setName(e.target.value)} className="char-create-input" maxLength={20} /> +
+ +
+ {['male', 'female', 'neutral'].map(g => ( + + ))} +
+
+
@@ -548,7 +678,21 @@ function CharacterCreator({ onConfirm }) {
- +
+ +
+ {[ + { id: 'instant_kill', label: 'Instant Kill' }, + { id: 'instant_ko', label: 'Instant Knockout' }, + { id: 'pocket_dimension', label: 'Pocket Dimension' }, + ].map(a => ( + + ))} +
+
+ + ) @@ -561,7 +705,7 @@ function CharacterCreator({ onConfirm }) { export default function App() { // ── world state const [player, setPlayer] = useState(createCharacter({ - id: 'player', name: 'John', age: 24, location: 'A', money: 50, + id: 'player', name: 'John', age: 24, location: startLocId, money: 50, inventory: [], equipped: { left: 'fists', right: 'fists' }, weaponConditions: {}, @@ -615,6 +759,8 @@ export default function App() { const [weaponsOpen, setWeaponsOpen] = useState([]) const [itemsOpen, setItemsOpen] = useState([]) const [movementOpen, setMovementOpen] = useState([]) + const [abilitiesOpen, setAbilitiesOpen] = useState([]) + const [debugAbilityOpen, setDebugAbilityOpen] = useState(null) const [skillDropdownOpen,setSkillDropdownOpen]= useState([]) const [limbSelect, setLimbSelect] = useState(null) const [selectedTarget, setSelectedTarget] = useState(null) @@ -673,6 +819,7 @@ export default function App() { const combatTimeRef = useRef(0); combatTimeRef.current = combatTime const displayTimeRef = useRef(0); displayTimeRef.current = displayTime const timerActiveRef = useRef(false) + const manualTimerRef = useRef(false) const [timerRunning, setTimerRunning] = useState(false) const prevBusyRef = useRef(false) const speedRef = useRef(1) @@ -680,6 +827,11 @@ export default function App() { const playerRef = useRef(null); playerRef.current = player const magazineRef = useRef({ pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }); magazineRef.current = player.weaponMagazines const deadThisTickRef = useRef(new Set()) + const debugAbilitiesRef = useRef([]) + const pocketReturnRef = useRef(null) + const pocketNPCsRef = useRef({}) // { npcId: originalLocation } + const pocketNPCDataRef = useRef({}) // { npcId: snapshotCharacterData } + const pocketDimIdRef = useRef(null) // stable pocket dimension ID // ── Universal character action/direction/position refs ── // Every character (player, enemies) uses the same ref structure. @@ -886,7 +1038,11 @@ export default function App() { 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 targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : ( + cc ? Object.keys(cc.characters).find(cid => { + return cc.characters[cid]?.owner === 'player' + }) ?? cc.playerEntityId : cc?.playerEntityId + )) const attacker = cc.characters[attackerId] const target = cc.characters[targetId] const startPos = attacker ? { ...attacker.pos } : { x: 20, y: 75 } @@ -1132,10 +1288,8 @@ export default function App() { return } const targetPart = randomPart() - const enemyIndex = (cc.enemyIds ?? []).indexOf(charId) const action = buildPlayerAttack({ weaponId, skillName, targetPart, - enemyIndex: enemyIndex >= 0 ? enemyIndex : 0, equippedWeapons: actorChar.equipped ?? { left: 'fists', right: 'fists' }, weaponConditions: actorChar.weaponConditions ?? {}, bodyParts: actorChar.integrity, @@ -1159,6 +1313,16 @@ export default function App() { } const anim = createWeaponStrikeAnim(action, cc) characterActionsRef.current[actorId] = anim + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + const tgt = chars[charId] + const act = chars[actorId] + if (tgt) chars[charId] = { ...tgt, relationships: { ...tgt.relationships, [actorId]: 'enemy' } } + if (act) chars[actorId] = { ...act, relationships: { ...act.relationships, [charId]: 'enemy' } } + return { ...c, characters: chars } + }) + setPlayer(p => ({ ...p, relationships: { ...(p.relationships ?? {}), [charId]: 'enemy' } })) setLimbSelect(null); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]); setAimMode(false) } @@ -1244,9 +1408,12 @@ class MoveAction { characterVelocitiesRef.current[this.charId] = { x: 0, y: 0 } return true } + const cc = combatRef.current + const charMaxSpeed = (cc?.characters[this.charId]?.maxSpeed ?? 1) * (this.speedMult ?? 1) + const movementFactor = cc ? getMovement(cc.characters[this.charId]?.integrity) / 100 : 1 characterVelocitiesRef.current[this.charId] = { - x: (dx / dist) * this.speed * 60, - y: (dy / dist) * this.speed * 60, + x: (dx / dist) * charMaxSpeed * 60 * movementFactor, + y: (dy / dist) * charMaxSpeed * 60 * movementFactor, } return false } @@ -1256,7 +1423,11 @@ class MoveAction { const liveCc = combatRef.current const attackerId = anim.charId const isPlayerSide = anim.actor === 'player' || anim.actor === 'party' - const targetId = anim.targetCharId ?? (isPlayerSide ? anim.enemyCharId : (liveCc?.partyIds?.[0] ?? liveCc?.playerEntityId)) + const targetId = anim.targetCharId ?? (isPlayerSide ? anim.enemyCharId : ( + liveCc ? Object.keys(liveCc.characters).find(cid => { + return liveCc.characters[cid]?.owner === 'player' + }) ?? liveCc.playerEntityId : liveCc?.playerEntityId + )) let inRange = false if (liveCc) { @@ -1356,7 +1527,11 @@ class MoveAction { : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt) // Find target party member - const targetCharId = anim.targetCharId ?? (combat?.partyIds?.[0] ?? 'player') + const targetCharId = anim.targetCharId ?? ( + combat ? Object.keys(combat.characters).find(cid => { + return combat.characters[cid]?.owner === 'player' + }) ?? 'player' : 'player' + ) const targetChar = liveCc?.characters[targetCharId] const targetIntegrity = targetChar?.integrity ?? playerRef.current.integrity const targetInjuries = targetChar?.injuries ?? playerRef.current.injuries @@ -1400,39 +1575,94 @@ class MoveAction { function stepPhysics(dt, blocks) { const blk = blocks ?? [] + const charIds = Object.keys(characterPositionsRef.current) 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 + + // Multiple iterations for stable constraint resolution + for (let iter = 0; iter < 2; iter++) { + for (const cid of charIds) { + const pos = characterPositionsRef.current[cid] + if (!pos) continue + const vel = characterVelocitiesRef.current[cid] + if (!vel) continue + + // Unconscious characters cannot move + const cc = combatRef.current + const ch = cc?.characters[cid] + if (ch && isUnconscious(ch.integrity)) { + if (vel.x !== 0 || vel.y !== 0) { vel.x = 0; vel.y = 0 } + continue + } + + let nx = pos.x, ny = pos.y + + // 1. Velocity integration (sub-stepped for collision safety) + if (vel.x !== 0 || vel.y !== 0) { + 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) { + const leftPen = sx - hitBlock.x + const rightPen = (hitBlock.x + hitBlock.w) - sx + const topPen = sy - hitBlock.y + const bottomPen= (hitBlock.y + hitBlock.h) - sy + const minPenX = Math.min(leftPen, rightPen) + const minPenY = Math.min(topPen, bottomPen) + if (minPenX < minPenY) { vel.x = 0; sx = leftPen < rightPen ? hitBlock.x : hitBlock.x + hitBlock.w } + else { vel.y = 0; sy = topPen < bottomPen ? hitBlock.y : hitBlock.y + hitBlock.h } + } + nx = Math.max(5, Math.min(145, sx)) + ny = Math.max(5, Math.min(145, sy)) + } + 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 + } + + // 2. Block containment — push toward nearest edge if inside a block + for (const b of blk) { + if (!rectCollides(nx, ny, b)) continue + const leftPen = nx - b.x + const rightPen = (b.x + b.w) - nx + const topPen = ny - b.y + const bottomPen= (b.y + b.h) - ny + const minPenX = Math.min(leftPen, rightPen) + const minPenY = Math.min(topPen, bottomPen) + if (minPenX < minPenY) { nx = leftPen < rightPen ? b.x : b.x + b.w } + else { ny = topPen < bottomPen ? b.y : b.y + b.h } + } + + // 3. Character-character proximity — push apart when too close + for (const otherCid of charIds) { + if (otherCid === cid) continue + const otherPos = characterPositionsRef.current[otherCid] + if (!otherPos) continue + const dx = nx - otherPos.x + const dy = ny - otherPos.y + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < 10 && dist > 0.001) { + const push = (10 - dist) * 0.5 + const pushX = (dx / dist) * push + const pushY = (dy / dist) * push + nx += pushX; ny += pushY + otherPos.x -= pushX; otherPos.y -= pushY + updates[otherCid] = { x: otherPos.x, y: otherPos.y } } } - nx = Math.max(5, Math.min(145, sx)) - ny = Math.max(5, Math.min(145, sy)) + + // 4. Clamp to world bounds + nx = Math.max(5, Math.min(145, nx)) + ny = Math.max(5, Math.min(145, ny)) + pos.x = nx; pos.y = ny + updates[cid] = { x: nx, y: ny } } - 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 => { @@ -1494,20 +1724,31 @@ class MoveAction { // ── Auto timer on busy state transitions; manual toggle via spacebar ── { const cc = combatRef.current - const partyIds = cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean) - const allBusy = partyIds.length > 0 && partyIds.every(cid => { + const partyCids = cc ? Object.keys(cc.characters).filter(cid => { const ch = cc.characters[cid] - return isDead(ch?.integrity) || characterActionsRef.current[cid] != null + return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + }) : [cc?.playerEntityId].filter(Boolean) + const allBusy = partyCids.length > 0 && partyCids.every(cid => { + const ch = cc.characters[cid] + if (isDead(ch?.integrity)) return true + const action = characterActionsRef.current[cid] + return action != null && !action.cancellable + }) + const anyQueued = partyCids.length > 0 && partyCids.every(cid => { + const ch = cc.characters[cid] + if (isDead(ch?.integrity)) return true + return characterActionsRef.current[cid] != null }) const wasBusy = prevBusyRef.current prevBusyRef.current = allBusy setBusy(allBusy) - if (allBusy && !wasBusy && !timerActiveRef.current && partyIds.some(cid => !isDead(cc.characters[cid]?.integrity))) { + if (anyQueued && !timerActiveRef.current && partyCids.some(cid => !isDead(cc.characters[cid]?.integrity))) { timerActiveRef.current = true + manualTimerRef.current = false setTimerRunning(true) } - if (!allBusy && wasBusy && timerActiveRef.current) { + if (!anyQueued && timerActiveRef.current && !manualTimerRef.current) { timerActiveRef.current = false setTimerRunning(false) } @@ -1517,8 +1758,11 @@ class MoveAction { { const cc = combatRef.current if (cc && timerActiveRef.current) { - const partyIds = cc.partyIds ?? [cc.playerEntityId].filter(Boolean) - if (partyIds.every(cid => isDead(cc.characters[cid]?.integrity))) { + const partyCids = Object.keys(cc.characters).filter(cid => { + const ch = cc.characters[cid] + return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + }) + if (partyCids.every(cid => isDead(cc.characters[cid]?.integrity))) { stopTimer() } } @@ -1528,6 +1772,14 @@ class MoveAction { if (!timerActiveRef.current) { lastTime = now setAnimations([]) + // Clear stale NPC actions so fresh AI actions can be queued when timer resumes + const ccr = combatRef.current + if (ccr) { + for (const cid of Object.keys(ccr.characters)) { + const ch = ccr.characters[cid] + if (ch && ch.owner !== 'player') characterActionsRef.current[cid] = null + } + } return } @@ -1566,8 +1818,8 @@ class MoveAction { // ── Tick ALL character actions uniformly ── if (cc) { for (const cid of Object.keys(cc.characters)) { - // Skip dead characters — clear their action and move on - if (isDead(cc.characters[cid]?.integrity) || deadThisTickRef.current.has(cid)) { + // Skip dead or unconscious characters — clear their action and move on + if (isDead(cc.characters[cid]?.integrity) || isUnconscious(cc.characters[cid]?.integrity) || deadThisTickRef.current.has(cid)) { if (characterActionsRef.current[cid]) { characterActionsRef.current[cid] = null } @@ -1626,18 +1878,21 @@ class MoveAction { } if (done) { // ── Enemy move → attack transition ── - // 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] + if (rawAnim.type === 'move' && cid !== playerCharId) { + const char = cc.characters[cid] + if (!char || char.owner === 'player') continue + const e = fresh?.characters[cid] ?? char 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 - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2) - if (d < minDist) { minDist = d; targetId = pid } + if (e && !isDead(e.integrity) && !isUnconscious(e.integrity)) { + let targetId = null, minDist = Infinity, targetPos = null + if (e.relationships) { + for (const [pid, rel] of Object.entries(e.relationships)) { + if (rel !== 'enemy') continue + const pch = (fresh ?? cc).characters[pid] + if (!pch || isDead(pch.integrity) || deadThisTickRef.current.has(pid)) continue + const d = Math.sqrt((pch.pos.x - charPos.x) ** 2 + (pch.pos.y - charPos.y) ** 2) + if (d < minDist) { minDist = d; targetId = pid; targetPos = pch.pos } + } } if (targetId) { const targetChar = (fresh ?? cc).characters[targetId] @@ -1663,6 +1918,28 @@ class MoveAction { characterActionsRef.current[cid] = createWeaponStrikeAnim(action, fresh ?? cc) continue } + // Still out of range after move → keep chasing with fresh target + if (targetPos) { + const dx = targetPos.x - charPos.x, dy = targetPos.y - charPos.y + const d = Math.sqrt(dx * dx + dy * dy) + const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } + characterActionsRef.current[cid] = new ParallelAction({ + type: 'move', + actions: [ + new TurnAction({ + targetDir: dir, + getDir: () => characterDirsRef.current[cid] ?? { dx: 0, dy: 1 }, + setDir: d2 => { characterDirsRef.current[cid] = d2 } + }), + new MoveAction({ + charId: cid, fromX: charPos.x, fromY: charPos.y, + toX: targetPos.x, toY: targetPos.y, + blocks: cc.blocks, + }) + ] + }) + continue + } } } } @@ -1679,12 +1956,20 @@ class MoveAction { if (cc) { stepPhysics(dt, cc.blocks) } // ── Run enemy AI every frame for idle enemies ── - if (fresh) { + if (fresh && timerActiveRef.current) { const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries, deadThisTickRef.current) for (const act of actions) { - if (act.type === 'attack' && !characterActionsRef.current[act.charId]) { + const actor = fresh.characters[act.charId] + if (!actor || isDead(actor.integrity) || isUnconscious(actor.integrity)) continue + if (act.type === 'attack') { + const target = fresh.characters[act.targetCharId] + if (!target || isDead(target.integrity)) continue + } + const existing = characterActionsRef.current[act.charId] + const isMove = existing?.type === 'move' + if (act.type === 'attack' && (!existing || isMove)) { characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) - } else if (act.type === 'move' && !characterActionsRef.current[act.charId]) { + } else if (act.type === 'move') { const dx = act.toX - act.fromX, dy = act.toY - act.fromY const d = Math.sqrt(dx * dx + dy * dy) const dir = d > 0 ? { dx: dx / d, dy: dy / d } : { dx: 0, dy: 1 } @@ -1746,12 +2031,12 @@ class MoveAction { useEffect(() => { if (!combat || replayActive) return - const enemyIds = combat.enemyIds ?? [] - if (enemyIds.length === 0) return - const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) - const allPartyDead = partyIds.every(cid => isDead(combat.characters[cid]?.integrity)) - const allEnemiesDead = !enemyIds.some(eid => !isDead(combat.characters[eid].integrity)) - if (allPartyDead || allEnemiesDead) endFight() + const playerDead = combat.characters['player'] && isDead(combat.characters['player'].integrity) + const aliveNonPlayer = Object.keys(combat.characters).some(cid => { + const ch = combat.characters[cid] + return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }) + if (playerDead || !aliveNonPlayer) endFight() }, [combat, player.integrity, playerHp]) // ───────────────────────────────────────────────────────────────────────── @@ -1888,13 +2173,13 @@ class MoveAction { // COMBAT ACTIONS // ───────────────────────────────────────────────────────────────────────── - const makeEnemy = (npc, index) => { - return createCharacter({ - ...npc, - id: npc.id ?? `enemy_${index}`, - pos: { x: 120 + index * 20, y: 60 + index * 20 }, - }) - } +const makeEnemy = (npc, index) => { + return createCharacter({ + ...npc, + id: npc.id ?? `enemy_${index}`, + pos: { x: 120 + index * 20, y: 60 + index * 20 }, + }) +} const startFight = (npcList) => { const list = npcList ?? [] @@ -1923,6 +2208,9 @@ class MoveAction { weaponConditions: { ...player.weaponConditions }, weaponMagazines: { ...player.weaponMagazines }, itemCounts: { ...player.itemCounts }, + relationships: { ...(player.relationships ?? {}) }, + owner: 'player', + fillColor: player.fillColor, }) for (const pc of livePartyChars) { characters[pc.id] = createCharacter({ @@ -1936,19 +2224,17 @@ class MoveAction { itemCounts: { ...(pc.itemCounts ?? {}) }, inventory: [...(pc.inventory ?? [])], size: pc.size ?? 1, - fillColor: pc.fillColor, + fillColor: pc.fillColor, + owner: null, }) } for (const e of enemies) { const cid = e.id characters[cid] = { ...e, pos: { ...e.pos } } } - const enemyIds = enemies.map(e => e.id) const initCombat = { playerEntityId: 'player', - partyIds: partyCharIds, characters, - enemyIds, log: [`You engage ${enemies.map(e => e.name).join(' and ')} in combat!`], blocks: generateLocationBlocks(), } @@ -1968,7 +2254,9 @@ class MoveAction { characterDirsRef.current[cid] = { dx: 0, dy: 1 } } setSelectedEnemy(0); setReplayActive(false); setCurrentActor(null) - setExpandedWeapon(['player']); setAnimations([]); setHitShake(null); setMoveMode(null) + setExpandedWeapon(Object.keys(characters).filter(cid => { + const ch = characters[cid]; return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + })); setAnimations([]); setHitShake(null); setMoveMode(null) recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), @@ -1991,7 +2279,9 @@ class MoveAction { 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([]) + setExpandedWeapon(Object.keys(characters).filter(cid => { + const ch = characters[cid]; return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + })); setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([]) return } @@ -2006,19 +2296,19 @@ class MoveAction { weaponConditions: { ...pd.weaponConditions }, weaponMagazines: { ...pd.weaponMagazines }, itemCounts: { ...pd.itemCounts }, + relationships: { ...(pd.relationships ?? {}) }, + owner: 'player', + fillColor: pd.fillColor, }) - const partyCharIds = ['player'] - const npcIds = [] const liveChars = liveCharacters.filter(c => c.id !== 'player' && c.location === locationId && (!c.integrity || !isDead(c.integrity))) let xOff = 50 for (const lc of liveChars) { if (lc.equipped) { - partyCharIds.push(lc.id) characters[lc.id] = createCharacter({ id: lc.id, name: lc.name, pos: { x: xOff, y: 130 }, - integrity: { ...lc.integrity }, + integrity: lc.integrity ? { ...lc.integrity } : undefined, injuries: JSON.parse(JSON.stringify(lc.injuries ?? pd.injuries)), equipped: { ...lc.equipped }, weaponConditions: { ...lc.weaponConditions }, @@ -2026,9 +2316,10 @@ class MoveAction { itemCounts: { ...lc.itemCounts }, inventory: [...(lc.inventory ?? [])], fillColor: lc.fillColor, + relationships: lc.relationships ? { ...lc.relationships } : undefined, + owner: null, }) } else { - npcIds.push(lc.id) characters[lc.id] = createCharacter({ id: lc.id, name: lc.name, pos: { x: xOff, y: 130 }, @@ -2040,6 +2331,8 @@ class MoveAction { itemCounts: { ...(lc.itemCounts ?? {}) }, inventory: [...(lc.inventory ?? [])], fillColor: lc.fillColor, + relationships: lc.relationships ? { ...lc.relationships } : undefined, + owner: null, }) } xOff += 25 @@ -2048,10 +2341,7 @@ class MoveAction { const locName = locMap[locationId]?.name ?? locationId const initCombat = { playerEntityId: 'player', - partyIds: partyCharIds, - npcIds, characters, - enemyIds: [], log: [`You arrive at ${locName}.`], blocks: generateLocationBlocks(), } @@ -2083,15 +2373,22 @@ class MoveAction { 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) + const deadIds = Object.keys(combat.characters).filter(cid => { + const ch = combat.characters[cid] + return ch && ch.owner !== 'player' && ch.relationships?.player === 'enemy' && isDead(ch.integrity) + }) if (deadIds.length > 0) setDefeatedNpcs(prev => [...new Set([...prev, ...deadIds])]) // Sync party member combat state back to world const cc = combatRef.current if (cc) { + const partyCids = Object.keys(cc.characters).filter(cid => { + const ch = cc.characters[cid] + return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + }) setCharacterOverrides(prev => { const next = { ...prev } - for (const pid of cc.partyIds ?? []) { + for (const pid of partyCids) { if (pid === 'player') continue const combatChar = cc.characters[pid] if (combatChar) { @@ -2107,7 +2404,7 @@ class MoveAction { } return next }) - for (const pid of cc.partyIds ?? []) { + for (const pid of partyCids) { if (pid === 'player') continue if (isDead(cc.characters[pid]?.integrity)) { setDefeatedNpcs(prev => [...new Set([...prev, pid])]) @@ -2123,9 +2420,6 @@ class MoveAction { 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, @@ -2146,12 +2440,12 @@ class MoveAction { weaponConditions: { ...pd.weaponConditions }, weaponMagazines: { ...pd.weaponMagazines }, itemCounts: { ...pd.itemCounts }, + relationships: { ...(pd.relationships ?? {}) }, + owner: 'player', + fillColor: pd.fillColor, }) 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, @@ -2182,11 +2476,13 @@ class MoveAction { if (!cc) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: no combat'] })); return } const actorChar = cc.characters[actorId] if (!actorChar || isDead(actorChar.integrity)) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: actor dead'] })); return } - const ei = attackEnemy ?? selectedEnemy - const enemyId = cc.enemyIds[ei] - const enemy = cc.characters[enemyId] + const targetId = attackEnemy ?? Object.keys(cc.characters).find(cid => { + const ch = cc.characters[cid] + return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }) + const enemy = targetId ? cc.characters[targetId] : null if (!enemy || isDead(enemy.integrity)) { - setCombat(c => ({ ...c, log: [...c.log, `DBG: bad enemy ei=${ei} id=${enemyId} enemy=${!!enemy} dead=${enemy ? isDead(enemy.integrity) : '?'}`] })) + setCombat(c => ({ ...c, log: [...c.log, `DBG: no valid target`] })) return } @@ -2202,7 +2498,7 @@ class MoveAction { const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, - enemyIndex: ei, + targetCharId: targetId, equippedWeapons: actorChar.equipped ?? { left: 'fists', right: 'fists' }, weaponConditions: actorChar.weaponConditions ?? {}, bodyParts: actorChar.integrity, @@ -2235,14 +2531,172 @@ class MoveAction { setAimMode(false) } + const debugInstantKill = (targetId) => { + const cc = combatRef.current + if (!cc) return + const target = cc.characters[targetId] + if (!target || isDead(target.integrity)) return + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + const integrity = propagateDestruction({ ...chars[targetId].integrity, head: 0 }) + chars[targetId] = { ...chars[targetId], integrity } + return { ...c, characters: chars, log: [...c.log, `DEBUG: ${chars[targetId].name ?? targetId} killed instantly.`] } + }) + } + + const debugInstantKO = (targetId) => { + const cc = combatRef.current + if (!cc) return + const target = cc.characters[targetId] + if (!target || isDead(target.integrity)) return + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + const integrity = propagateDestruction({ ...chars[targetId].integrity, head: 1 }) + chars[targetId] = { ...chars[targetId], integrity } + return { ...c, characters: chars, log: [...c.log, `DEBUG: ${chars[targetId].name ?? targetId} knocked out instantly.`] } + }) + } + + const debugPocketDimension = (targetId) => { + const cc = combatRef.current + if (!cc) return + const locId = player.location + if (locId?.startsWith?.('pocket_')) { + const returnLoc = pocketReturnRef.current + if (returnLoc) { + const restored = [] + for (const npcId of Object.keys(pocketNPCsRef.current)) { + const origLoc = pocketNPCsRef.current[npcId] + setCharacterOverrides(prev => { + const next = { ...prev }; const existing = next[npcId] + if (existing) next[npcId] = { ...existing, location: origLoc } + else next[npcId] = { location: origLoc } + return next + }) + restored.push(npcId) + } + pocketNPCsRef.current = {} + pocketNPCDataRef.current = {} + pocketDimIdRef.current = null + enterLocation(returnLoc) + setMinimapView({ x: 0, y: 0, scale: 1 }) + const extra = restored.length ? ` (restored ${restored.map(id => cc.characters[id]?.name ?? id).join(', ')})` : '' + setCombat(c => c ? { ...c, log: [...c.log, `DEBUG: Returned from pocket dimension.${extra}`] } : c) + } + return + } + if (targetId && targetId !== 'player') { + const npcChar = cc.characters[targetId] + if (npcChar && !isDead(npcChar.integrity)) { + if (!pocketDimIdRef.current) pocketDimIdRef.current = `pocket_${Date.now()}` + const pocketId = pocketDimIdRef.current + pocketNPCDataRef.current[targetId] = { + id: targetId, name: npcChar.name, + integrity: { ...npcChar.integrity }, + injuries: JSON.parse(JSON.stringify(npcChar.injuries ?? playerRef.current.injuries)), + equipped: { ...(npcChar.equipped ?? { left: 'fists', right: 'fists' }) }, + weaponConditions: { ...(npcChar.weaponConditions ?? {}) }, + weaponMagazines: { ...(npcChar.weaponMagazines ?? {}) }, + itemCounts: { ...(npcChar.itemCounts ?? {}) }, + inventory: [...(npcChar.inventory ?? [])], + relationships: { ...(npcChar.relationships ?? {}) }, + size: npcChar.size, + owner: null, + fillColor: npcChar.fillColor, + } + pocketNPCsRef.current[targetId] = locId + setCharacterOverrides(prev => ({ + ...prev, [targetId]: { ...prev[targetId], location: pocketId } + })) + setCombat(c => { + if (!c) return c + const chars = { ...c.characters } + delete chars[targetId] + return { ...c, characters: chars, log: [...c.log, `DEBUG: ${npcChar.name ?? targetId} sent to pocket dimension.`] } + }) + } + return + } + pocketReturnRef.current = locId + savedMapsRef.current[locId] = saveCurrentMapState() + worldItemsRef.current[locId] = groundItemsRef.current + if (!pocketDimIdRef.current) pocketDimIdRef.current = `pocket_${Date.now()}` + const pocketId = pocketDimIdRef.current + const pos = { x: 80, y: 80 } + const pd = playerRef.current + const pocketChars = {} + pocketChars['player'] = createCharacter({ + id: 'player', name: pd.name, + pos, + integrity: { ...pd.integrity }, + injuries: JSON.parse(JSON.stringify(pd.injuries)), + equipped: { ...pd.equipped }, + weaponConditions: { ...pd.weaponConditions }, + weaponMagazines: { ...pd.weaponMagazines }, + itemCounts: { ...pd.itemCounts }, + relationships: { ...(pd.relationships ?? {}) }, + owner: 'player', + fillColor: pd.fillColor, + }) + let xOff = 105 + for (const npcId of Object.keys(pocketNPCDataRef.current)) { + const data = pocketNPCDataRef.current[npcId] + pocketChars[npcId] = createCharacter({ + id: data.id, name: data.name, + pos: { x: xOff, y: 80 }, + integrity: data.integrity, + injuries: data.injuries, + equipped: data.equipped, + weaponConditions: data.weaponConditions, + weaponMagazines: data.weaponMagazines, + itemCounts: data.itemCounts, + inventory: data.inventory, + relationships: data.relationships, + size: data.size, + owner: data.owner, + fillColor: data.fillColor, + }) + xOff += 25 + } + const initCombat = { + playerEntityId: 'player', + characters: pocketChars, + log: [`DEBUG: Entered pocket dimension.${Object.keys(pocketNPCDataRef.current).length ? ` (${Object.keys(pocketNPCDataRef.current).length} NPCs present)` : ''}`], + blocks: [], + } + setCombat(initCombat) + setMinimapView({ x: 0, y: 0, scale: 0.6 }) + setPlayer(p => ({ ...p, location: pocketId })) + setGroundItems([]); groundItemsRef.current = [] + setCombatTime(0); setDisplayTime(0) + characterActionsRef.current = {} + characterPositionsRef.current = {} + characterVelocitiesRef.current = {} + characterDirsRef.current = {} + for (const cid of Object.keys(pocketChars)) { + characterActionsRef.current[cid] = null + characterPositionsRef.current[cid] = pocketChars[cid].pos + characterVelocitiesRef.current[cid] = { x: 0, y: 0 } + characterDirsRef.current[cid] = { dx: 0, dy: 1 } + } + 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([]) + } + const startTimer = () => { if (!timerActiveRef.current) { timerActiveRef.current = true + manualTimerRef.current = true setTimerRunning(true) } } const stopTimer = () => { timerActiveRef.current = false + manualTimerRef.current = false setTimerRunning(false) } const toggleTimer = () => { @@ -2267,7 +2721,7 @@ class MoveAction { } } const onKeyDown = (e) => { - if (e.code === 'Space' && combatRef.current) { e.preventDefault(); toggleTimer() } + if (e.code === 'Space' && combatRef.current) { e.preventDefault(); if (e.repeat || e.target.closest('button,input,textarea,select')) return; toggleTimer() } if (e.key === 'Escape' && aimModeRef.current) { setAimMode(false); setSelectedSkill(null) } const k = e.key.toLowerCase() if (!'wasd'.includes(k)) return @@ -2289,18 +2743,17 @@ class MoveAction { if (!combat) return const actorId = currentActor ?? 'player' const ch = combat.characters[actorId] - if (!ch) return + if (!ch || isUnconscious(ch.integrity)) return const curPos = characterPositionsRef.current[actorId] ?? ch.pos const dist = Math.sqrt((targetX - curPos.x) ** 2 + (targetY - curPos.y) ** 2) if (dist < 1) return - const maxSpd = ch.maxSpeed ?? 10 const pct = moveMode === 'run' ? 1.0 : 0.5 - const speed = maxSpd * pct - const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speed, blocks: combat.blocks }) + const moveAction = new MoveAction({ type: 'move', charId: actorId, fromX: curPos.x, fromY: curPos.y, toX: targetX, toY: targetY, speedMult: pct, 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 } characterActionsRef.current[actorId] = new ParallelAction({ + cancellable: true, actions: [ new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current[actorId] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current[actorId] = d } }), moveAction @@ -2776,9 +3229,10 @@ class MoveAction { if (showCharCreate) { return { - const { name, weaponId, fillColor, locationId } = opts + const { name, gender, weaponId, fillColor, locationId, debugAbilities } = opts + debugAbilitiesRef.current = debugAbilities ?? [] const newPlayer = createCharacter({ - id: 'player', name, age: 24, location: locationId, money: 50, + id: 'player', name, gender, age: 24, location: locationId, money: 50, inventory: weaponId === 'pistol' ? ['pistol_ammo', 'bouncing_ammo'] : [], equipped: { left: weaponId === 'pistol' ? 'pistol' : 'fists', right: weaponId === 'pistol' ? 'fists' : weaponId }, weaponConditions: weaponId !== 'fists' ? { [weaponId]: weaponMap[weaponId]?.maxCondition ?? 100 } : {}, @@ -2868,11 +3322,14 @@ class MoveAction { {/* ── left: action tree (per party member) */}
{combat && (() => { - const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) - const anyActionPossible = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity)) + const partyCids = Object.keys(combat.characters).filter(cid => { + const ch = combat.characters[cid] + return ch && (ch.owner === 'player' || ch.relationships?.player === 'ally') + }) + const anyActionPossible = partyCids.some(cid => !isDead(combat.characters[cid]?.integrity)) return anyActionPossible && (
- {partyIds.map(cid => { + {partyCids.map(cid => { const ch = combat.characters[cid] if (!ch) return null const isDeadChar = isDead(ch.integrity) @@ -2888,6 +3345,72 @@ class MoveAction {
{expandedWeapon.includes(cid) && (
+ {/* Abilities */} +
setAbilitiesOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> + {abilitiesOpen.includes(cid) ? '▼' : '▶'} + Abilities +
+ {abilitiesOpen.includes(cid) && ( +
+ {debugAbilitiesRef.current.includes('instant_kill') && (() => { + const enemies = combat ? Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }) : [] + return ( +
setDebugAbilityOpen(prev => prev === 'kill' ? null : 'kill')}> + {debugAbilityOpen === 'kill' ? '▼' : '▶'} + Instant Kill +
+ ) + })()} + {debugAbilityOpen === 'kill' && combat && Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }).map(eid => ( +
debugInstantKill(eid)}> + + {combat.characters[eid].name ?? eid} +
+ ))} + {debugAbilitiesRef.current.includes('instant_ko') && (() => { + const enemies = combat ? Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }) : [] + return ( +
setDebugAbilityOpen(prev => prev === 'ko' ? null : 'ko')}> + {debugAbilityOpen === 'ko' ? '▼' : '▶'} + Instant Knockout +
+ ) + })()} + {debugAbilityOpen === 'ko' && combat && Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && ch.owner !== 'player' && !isDead(ch.integrity) + }).map(eid => ( +
debugInstantKO(eid)}> + + {combat.characters[eid].name ?? eid} +
+ ))} + {debugAbilitiesRef.current.includes('pocket_dimension') && (() => { + const allChars = combat ? Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && !isDead(ch.integrity) + }) : [] + return ( +
setDebugAbilityOpen(prev => prev === 'pocket' ? null : 'pocket')}> + {debugAbilityOpen === 'pocket' ? '▼' : '▶'} + Pocket Dimension +
+ ) + })()} + {debugAbilityOpen === 'pocket' && combat && Object.keys(combat.characters).filter(id => { + const ch = combat.characters[id]; return ch && !isDead(ch.integrity) + }).map(eid => ( +
debugPocketDimension(eid)}> + + {eid === 'player' ? 'Player' : (combat.characters[eid].name ?? eid)} +
+ ))} +
+ )} {/* Weapons */}
setWeaponsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> {weaponsOpen.includes(cid) ? '▼' : '▶'} @@ -3035,7 +3558,7 @@ class MoveAction { if (aimModeRef.current) { if (selectedSkillRef.current !== 'Shoot' || limbSelectRef.current !== 'pistol') { const cc = combatRef.current - const allCharIds = [...(cc?.enemyIds ?? []), ...(cc?.npcIds ?? []), ...(cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean))] + const allCharIds = Object.keys(cc?.characters ?? {}) let closest = null, closestDist = 20 allCharIds.forEach(cid => { const ch = cc.characters[cid] @@ -3057,7 +3580,7 @@ class MoveAction { const dy = ty - pp.y const d = Math.sqrt(dx * dx + dy * dy) if (d > 0) { - characterActionsRef.current[turnActor] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current[turnActor] ?? { dx: 0, dy: 1 }, setDir: d2 => { characterDirsRef.current[turnActor] = d2 } }) + characterActionsRef.current[turnActor] = new TurnAction({ cancellable: true, targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current[turnActor] ?? { dx: 0, dy: 1 }, setDir: d2 => { characterDirsRef.current[turnActor] = d2 } }) setMoveMode(null); setCurrentActor(null) } return @@ -3080,35 +3603,35 @@ class MoveAction { }}> {(combat?.blocks ?? []).map((block, i) => )} - {(combat?.enemyIds ?? []).map((eid, idx) => { - const enemy = combat.characters[eid] - const dead = isDead(enemy.integrity) - const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' - const eSize = enemy.size ?? 1 - const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x - const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y - // Targeting in melee/ranged-aim mode: clicking the dot itself - // is a hard, direct hit-test — independent of the SVG's own - // click-coordinate nearest-enemy search above. This is what - // makes "click the dot" reliably select the target. + {/* All character circles */} + {combat && Object.keys(combat.characters).map(cid => { + const ch = combat.characters[cid] + if (!ch) return null + const dead = isDead(ch.integrity) + const isShaking = hitShake?.charId === cid + const cx = isShaking ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x + const cy = isShaking ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y + const chSize = ch.size ?? 1 + const radius = ch.owner === 'player' ? 4 * chSize : 5 * chSize const isTargetable = aimMode && !dead && (selectedSkill !== 'Shoot' || limbSelect !== 'pistol') - const isCurrentTarget = attackEnemy === idx + const hostileToPlayer = ch.relationships?.player === 'enemy' + const ownerStroke = ch.owner === 'player' ? '#3498db' : hostileToPlayer ? '#e74c3c' : '#22c55e' return ( - - setHoveredChar(`enemy-${eid}`)} - onMouseLeave={() => setHoveredChar(null)} - onClick={e => { - if (!isTargetable) return - e.stopPropagation() - attackCharTarget(eid) - }} /> - + setHoveredChar(cid)} + onMouseLeave={() => setHoveredChar(null)} + onClick={e => { + if (!isTargetable) return + e.stopPropagation() + attackCharTarget(cid) + }} + onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} /> ) })} {combat && selectedSkill && limbSelect && (() => { @@ -3160,103 +3683,59 @@ class MoveAction { ) })()} - {/* Party member circles */} - {combat && (() => { - const partyCircles = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) - return partyCircles.map(cid => { - const ch = combat.characters[cid] - if (!ch) return null - const chDead = isDead(ch.integrity) - const color = cid === 'player' ? player.fillColor : ch.fillColor - return ( - setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)} - onClick={e => { e.stopPropagation(); if (aimMode && limbSelect !== 'pistol') attackCharTarget(cid) }} - onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} /> - ) - }) - })()} - {/* Non-hostile NPC circles */} - {combat?.npcIds?.map(nid => { - const ch = combat.characters[nid] - if (!ch) return null - return ( - setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)} - onClick={e => { e.stopPropagation(); if (aimMode && limbSelect !== 'pistol') attackCharTarget(nid) }} - onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: nid }) }} /> - ) - })} - {/* Weapon circles for party members */} + {/* Weapon circles for all characters */} {combat && (() => { const wpnOff = (dir, side, off) => { if (side === 'front') return { x: dir.dx * off, y: dir.dy * off } if (side === 'left') return { x: dir.dy * off, y: -dir.dx * off } return { x: -dir.dy * off, y: dir.dx * off } } - const wpnDots = (slots, bx, by) => slots.map(s => - - ) 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, ANIM_TYPES.OVERHEAD].includes(t) const allSlots = [] - const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) - partyIds.forEach(cid => { + Object.keys(combat.characters).forEach(cid => { const ch = combat.characters[cid] if (!ch || isDead(ch.integrity)) return - const equipped = ch.equipped ?? { left: 'fists', right: 'fists' } - const lw = equipped.left, rw = equipped.right const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } const off = 7 const shake = hitShake?.charId === cid const bx = shake ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x const by = shake ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y - const bothWpn = lw === rw && !isFist(lw) const chPos = ch.pos - if (bothWpn) { - const anim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === lw) - const p = anim ? { x: getAnimPos(anim).x - chPos.x, y: getAnimPos(anim).y - chPos.y } : wpnOff(dir, 'front', off) - allSlots.push({ key: `pw-${cid}-both`, ox: p.x, oy: p.y, fill: '#fbbf24', bx, by }) - } else { - const lAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === lw || a.weaponId === 'left_arm')) - const rAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === rw || a.weaponId === 'right_arm')) - const bothAnim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === 'fists') - if (!isFist(lw) || lAnim || bothAnim) { - const ap = (isFist(lw) && bothAnim) ? bothAnim : lAnim - const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'left', off) - allSlots.push({ key: `pw-${cid}-left`, ox: p.x, oy: p.y, fill: isFist(lw) ? ch.fillColor : '#fbbf24', bx, by }) - } - if (!isFist(rw) || rAnim || bothAnim) { - const ap = (isFist(rw) && bothAnim) ? bothAnim : rAnim - const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'right', off) - allSlots.push({ key: `pw-${cid}-right`, ox: p.x, oy: p.y, fill: isFist(rw) ? ch.fillColor : '#fbbf24', bx, by }) + if (ch.equipped) { + const equipped = ch.equipped + const lw = equipped.left, rw = equipped.right + const bothWpn = lw === rw && !isFist(lw) + if (bothWpn) { + const anim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === lw) + const p = anim ? { x: getAnimPos(anim).x - chPos.x, y: getAnimPos(anim).y - chPos.y } : wpnOff(dir, 'front', off) + allSlots.push({ key: `w-${cid}-both`, ox: p.x, oy: p.y, fill: '#fbbf24', bx, by }) + } else { + const lAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === lw || a.weaponId === 'left_arm')) + const rAnim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === rw || a.weaponId === 'right_arm')) + const bothAnim = animations.find(a => a.charId === cid && isAtk(a.type) && a.weaponId === 'fists') + if (isFist(lw) || lAnim || bothAnim) { + const ap = (isFist(lw) && bothAnim) ? bothAnim : lAnim + const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'left', off) + allSlots.push({ key: `w-${cid}-left`, ox: p.x, oy: p.y, fill: isFist(lw) ? ch.fillColor : '#fbbf24', bx, by }) + } + if (isFist(rw) || rAnim || bothAnim) { + const ap = (isFist(rw) && bothAnim) ? bothAnim : rAnim + const p = ap ? { x: getAnimPos(ap).x - chPos.x, y: getAnimPos(ap).y - chPos.y } : wpnOff(dir, 'right', off) + allSlots.push({ key: `w-${cid}-right`, ox: p.x, oy: p.y, fill: isFist(rw) ? ch.fillColor : '#fbbf24', bx, by }) + } } + } else if (ch.weapon) { + const wpn = ch.weapon + const anim = animations.find(a => a.charId === cid && isAtk(a.type) && (a.weaponId === wpn || a.weaponId === 'fists')) + if (isFist(wpn) && !anim) return + const p = anim ? { x: getAnimPos(anim).x - chPos.x, y: getAnimPos(anim).y - chPos.y } : { x: -dir.dy * 7, y: dir.dx * 7 } + allSlots.push({ key: `w-${cid}`, ox: p.x, oy: p.y, fill: isFist(wpn) ? ch.fillColor : '#f97316', bx, by }) } }) const flat = allSlots.map(s => ) return flat })()} - {combat && combat.enemyIds.map(eid => { - const enemy = combat.characters[eid] - const dead = isDead(enemy.integrity) - if (dead) return null - const wpn = enemy.weapon - if (!wpn) return null - const dir = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } - const shake = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' - 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 || 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 - })} {/* Projectile dots */} {projectiles.map(p => ( @@ -3267,47 +3746,24 @@ class MoveAction { 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) - const anyAlive = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity)) - if (!anyAlive) return null - return partyIds.map(cid => { - const ch = combat.characters[cid] - if (!ch || isDead(ch.integrity)) return null - const d = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } - const isShaking = hitShake?.charId === cid - const cx = isShaking ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x - const cy = isShaking ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y - const isHovered = hoveredChar === cid - const px = -d.dy, py = d.dx - return ( - { + const ch = combat.characters[cid] + if (!ch || isDead(ch.integrity)) return null + const d = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } + const isShaking = hitShake?.charId === cid + const cx = isShaking ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x + const cy = isShaking ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y + const isHovered = hoveredChar === cid + const px = -d.dy, py = d.dx + const arrowColor = ch.owner === 'player' ? '#3498db' : ch.relationships?.player === 'enemy' ? '#e74c3c' : '#22c55e' + return ( + - ) - }) - })()} - {combat && combat.enemyIds.map(eid => { - const enemy = combat.characters[eid] - const dead = isDead(enemy.integrity) - if (dead) return null - const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' - const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x - const ey = isShaking ? enemy.pos.y + (Math.random() - 0.5) * 1.5 : enemy.pos.y - const d = characterDirsRef.current[eid] ?? { dx: 0, dy: 1 } - const isHovered = hoveredChar === `enemy-${eid}` - const px = -d.dy, py = d.dx - return ( - + fill={isHovered ? '#fff' : arrowColor} opacity="0.8" filter={isHovered ? 'url(#arrowGlow)' : undefined} /> ) })} @@ -3321,24 +3777,9 @@ class MoveAction {
- {/* ── right: enemy list */} -
-
- {(combat?.enemyIds ?? []).map(eid => { - const enemy = combat.characters[eid] - const idx = combat.enemyIds.indexOf(eid) - return ( -
{ setSelectedEnemy(idx); setAttackEnemy(idx); setSelectedTarget(null); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}> - {enemy.name} -
- ) - })} -
-
- - + )} {/* ═══════════════════════════════════════════════════════ WORLD MAP */} @@ -3353,20 +3794,17 @@ class MoveAction { onPointerUp={handleBgPointerUp} style={{ cursor: isPanning.current ? 'grabbing' : 'grab' }} /> - {connections.map((c, i) => { - const from = locMap[c.from], to = locMap[c.to] - return - })} + {locations.map(l => ( - setSelected(selected === l.id ? null : l.id)} onMouseEnter={() => setHovered(l.id)} onMouseLeave={() => setHovered(null)} /> - {l.id} + {l.name} {!travelAnim && player.location === l.id && ( <> P @@ -3401,13 +3839,7 @@ class MoveAction { )} -
- -
+ {selected && (
@@ -3468,6 +3900,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul +
{character && invTab === 'health' && ( @@ -3609,15 +4042,25 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
{isPlayer ? 'Character' : character.name}
Name{isPlayer ? 'You' : character.name}
+
Gender{character.gender ? character.gender.charAt(0).toUpperCase() + character.gender.slice(1) : 'Unknown'}
Age{character.age ?? '??'}
{isPlayer &&
Size{character.size ?? 1}x
}
Location{locMap[character.location]?.name ?? character.location}
-
-
-
Integrity{charHp}/{MAX_HP}
-
Items{character.inventory.length}
- {isPlayer &&
Weight{charWeight}/{MAX_WEIGHT}
} -
Money{character.money || 0} {CURRENCY}
+
+ )} + {character && invTab === 'relationships' && ( +
+
Relationships
+ {Object.keys(character.relationships ?? {}).length === 0 ? ( +
No relationships yet
+ ) : ( + Object.entries(character.relationships ?? {}).map(([id, type]) => ( +
+ {combat?.characters[id]?.name ?? id} + {type} +
+ )) + )}
)}
@@ -3785,7 +4228,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) + qty } } - setPlayer(newPlayer) + setPlayer({ ...newPlayer, relationships: { ...(newPlayer.relationships ?? {}), [tradeTarget]: 'ally' } }) if (combat) { setCombat(prev => { const chars = { ...prev.characters } @@ -3793,6 +4236,14 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul if (tradeTarget && chars[tradeTarget] && tgtWorld) { chars[tradeTarget] = { ...chars[tradeTarget], inventory: [...tgtWorld.inventory], itemCounts: { ...tgtWorld.itemCounts }, weaponConditions: { ...tgtWorld.weaponConditions } } } + if (tradeTarget && tradeTarget !== 'player') { + const myChar = chars.player + const tgtChar = chars[tradeTarget] + if (myChar && tgtChar) { + chars.player = { ...chars.player, relationships: { ...(chars.player.relationships ?? {}), [tradeTarget]: 'ally' } } + chars[tradeTarget] = { ...chars[tradeTarget], relationships: { ...(chars[tradeTarget].relationships ?? {}), player: 'ally' } } + } + } return { ...prev, log: [...prev.log, 'Trade completed.'], characters: chars } }) } @@ -3863,17 +4314,16 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul ) })()} - {hoverInfo?.type === 'enemy' && ( -
-
{hoverInfo.name}
-
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}
-
- )} - {hoveredChar && !hoveredChar.startsWith('enemy-') && combat?.characters[hoveredChar] && ( -
-
{combat.characters[hoveredChar].name ?? hoveredChar}
-
- )} + {hoveredChar && (() => { + const charId = hoveredChar.startsWith('enemy-') ? hoveredChar.slice(6) : hoveredChar + const ch = combat?.characters[charId] + if (!ch) return null + return ( +
+
{ch.name ?? charId}
+
+ ) + })()} {/* ═══════════════════════════════════════════════════════ EQUIP ARM CHOICE */} {equipArmChoice && ( diff --git a/src/names.json b/src/names.json new file mode 100644 index 0000000..4e001b8 --- /dev/null +++ b/src/names.json @@ -0,0 +1,47 @@ +{ + "male_names": [ + "Nicholas", "Isaiah", "Henry", "Landon", "Wesley", "Charles", "Andrew", "Ezra", + "Asher", "Thomas", "Austin", "Kayden", "Matthew", "Santiago", "Louis", "Jonathan", + "Christian", "Miles", "Aaron", "Rowan", "Bryce", "James", "Walker", "Sebastian", + "Logan", "Michael", "Jordan", "Christopher", "Zachary", "Levi", "Lucas", "Wyatt", + "Benjamin", "Gabriel", "Gavin", "Everett", "Hudson", "Elias", "Daniel", "Sawyer", + "Lincoln", "Dominic", "Robert", "Mateo", "Arlo", "John", "Nolan", "Vincent", + "Eli", "Hunter", "Maverick", "Jace", "Julian", "Axel", "Theodore", "Jack", + "Nathan", "Kingston", "Amir", "Elijah", "Caleb", "David", "Ian", "Brooks", + "August", "Josiah", "Leo", "Alexander", "Greyson", "Liam", "Cooper", "Jackson", + "Ryan", "Owen", "Weston", "Carson", "William", "Jaxson", "Samuel", "Bennett", + "Silas", "Carter", "Colton", "Mason", "Roman", "Evan", "Bryson", "Angel", + "Noah", "Oliver", "Arthur", "Beau", "Caelan", "Declan", "Emmett", "Felix", + "Graham", "Holden", "Ivan", "Jude" + ], + "female_names": [ + "Zoe", "Elizabeth", "Hadley", "Valerie", "Genevieve", "Quinn", "Aaliyah", "Eva", + "Abigail", "Alice", "Elena", "Naomi", "Serenity", "Emily", "Hazel", "Sienna", + "Lucy", "Audrey", "Aria", "Adeline", "Stella", "Eliana", "Gianna", + "Madelyn", "Fiona", "Gemma", "Kinsley", "Leilani", "Mia", "Maya", "Nova", + "Mila", "Savannah", "Lilah", "Athena", "Cora", "Grace", "Bella", "Charlotte", + "Isabella", "Lydia", "Nora", "Rylee", "Ivy", "Camila", "Violet", "Rosalie", + "Madison", "Wren", "Avery", "Harper", "Penelope", "Sophia", "Melanie", "Aubrey", + "Clara", "Maeve", "Valentina", "Scarlett", "Chloe", "Brielle", "Aurora", + "Josephine", "Layla", "Emma", "Victoria", "Milani", "Ruby", "Piper", "Willow", + "Sadie", "Ayla", "Paisley", "Sofia", "Remi", "Melody", "Delilah", "Natalie", + "Vivian", "Iris", "Charlie", "Jade", "Ava", "Evelyn", "Luna", "Adalyn", + "Kora", "Amara", "Beatrice", "Daphne", "Elise", "Freya", "Georgia", "Helena", + "Imogen", "Juliet", "Kira", "Leila" + ], + "neutral_names": [ + "Alex", "Morgan", "Casey", "Riley", "Jordan", "Avery", "Quinn", "Blake", + "Cameron", "Dakota", "Emerson", "Finley", "Harper", "Jessie", "Kennedy", + "Logan", "Marley", "Oakley", "Parker", "Reese", "Rowan", "Sage", + "Skyler", "Taylor", "Tristan", "Winter", "Ash", "Briar", "Cedar", + "Drew", "Ellis", "Gale", "Hayden", "Jules", "Kai", "Lane", "Marlowe", + "Nico", "Onyx", "Perry", "Remy", "Shae", "Sidney", "Tatum", "Wren", + "Zion", "Arden", "Bellamy", "Channing", "Dale", "Eden", "Flannery", + "Gray", "Haven", "Indigo", "Joss", "Kerry", "Linden", "Merritt", + "Neal", "Orion", "Peyton", "Rain", "Sterling", "Tracy", "Vesper", + "Whittaker", "Yael", "Zephyr", "Ainsley", "Brett", "Collins", "Devon", + "Erie", "Farrell", "Greer", "Hollis", "Ira", "Jody", "Kelsey", + "Lark", "Merrill", "Nevada", "Olive", "Penn", "Raleigh", "Shiloh", + "Teal", "Upton", "Vale", "Ward", "Wynn", "Yale", "Baylor" + ] +}