From a066f1527973d0b395482800bf5656b8d474040a Mon Sep 17 00:00:00 2001 From: johnruina Date: Mon, 15 Jun 2026 10:18:07 -0400 Subject: [PATCH] rimworld typa stuff --- src/App.css | 514 +++++++---- src/App.jsx | 1742 ++++++++++++++++++++++++------------- src/assets/pistolshot.mp3 | Bin 0 -> 30374 bytes 3 files changed, 1487 insertions(+), 769 deletions(-) create mode 100644 src/assets/pistolshot.mp3 diff --git a/src/App.css b/src/App.css index 7f0f3cb..04c9483 100644 --- a/src/App.css +++ b/src/App.css @@ -91,74 +91,12 @@ body { background: #b91c1c; } -.combat-panel { - position: absolute; - inset: 0; - background: rgba(15, 15, 26, 0.75); - z-index: 100; - display: flex; - flex-direction: column; - align-items: center; - padding: 16px 60px 24px; -} - -.combat-over-panel { - z-index: 101; - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - padding: 24px; -} - -.combat-over-title { - color: #fbbf24; - font-size: 20px; - font-weight: bold; -} - -.combat-over-btns { - display: flex; - gap: 12px; -} - -.combat-over-btn { - background: #2e303a; +.game-time-display { color: #e0e0e0; - border: 1px solid #5F71C5; - border-radius: 1px; - padding: 10px 24px; - cursor: pointer; - font-size: 15px; + font-size: 17px; + font-weight: bold; + letter-spacing: 1px; } - -.combat-over-btn:hover { - background: #5F71C5; - color: #fff; -} - -.combat-over-btn.primary { - background: #5F71C5; - color: #fff; -} - -.combat-over-btn.primary:hover { - background: #4a5cb0; -} - -.combat-top-bar { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.combat-top-left { - display: flex; - gap: 8px; -} - .combat-timer { color: #e0e0e0; font-size: 20px; @@ -204,12 +142,13 @@ body { .combat-layout { + position: absolute; + top: 72px; + left: 0; + right: 0; + bottom: 0; display: flex; justify-content: center; - width: 100%; - flex: 1; - position: relative; - min-height: 0; } .combat-left { @@ -1163,34 +1102,6 @@ body { background: #5F71C5; } -.zoom-controls { - position: absolute; - bottom: 40px; - right: 40px; - display: flex; - flex-direction: column; - gap: 4px; - z-index: 10; -} - -.zoom-controls button { - width: 40px; - height: 40px; - background: #1e1e30; - border: 1px solid #5F71C5; - border-radius: 1px; - color: #5F71C5; - font-size: 22px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - user-select: none; -} - -.zoom-controls button:hover { - background: #2a2a44; -} .pass-time-panel { position: absolute; @@ -1441,7 +1352,7 @@ body { left: 0; right: 0; padding: 16px; - z-index: 10; + z-index: 200; display: flex; justify-content: space-between; align-items: center; @@ -1480,31 +1391,12 @@ body { } .top-bar-right { + pointer-events: auto; display: flex; align-items: center; gap: 16px; } -.time-display { - color: #9ca3af; - font-size: 14px; - font-weight: bold; - background: #1e1e30; - border: 1px solid #2e303a; - border-radius: 1px; - padding: 8px 16px; -} - -.weather-display { - color: #5F71C5; - font-size: 14px; - font-weight: bold; - background: #1e1e30; - border: 1px solid #5F71C5; - border-radius: 1px; - padding: 8px 16px; -} - .inv-btn { background: #1e1e30; color: #5F71C5; @@ -1520,51 +1412,36 @@ body { background: #2a2a44; } -.shop-section { - border-top: 1px solid #2e303a; - margin-top: 12px; - padding-top: 12px; +.context-menu { + position: absolute; + z-index: 300; + background: #1a1a2e; + border: 1px solid #2d2d44; + border-radius: 4px; + padding: 4px 0; + min-width: 120px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); } -.shop-item { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; +.context-menu-btn { + display: block; + width: 100%; + background: none; + border: none; + color: #e0e0e0; + padding: 8px 16px; + text-align: left; + cursor: pointer; font-size: 13px; } -.shop-item-name { - flex: 1; - text-align: left; - color: #e0e0e0; +.context-menu-btn:hover:not(.disabled) { + background: #2d2d44; } -.shop-item-price { - color: #fbbf24; - width: 40px; - text-align: right; -} - -.buy-btn { - background: #22c55e; - color: #000; - border: none; - border-radius: 1px; - padding: 4px 12px; - cursor: pointer; - font-size: 12px; - font-weight: bold; -} - -.buy-btn:disabled { - background: #4b5563; - color: #9ca3af; - cursor: not-allowed; -} - -.buy-btn:hover:not(:disabled) { - background: #16a34a; +.context-menu-btn.disabled { + color: #5a5a6a; + cursor: default; } .inventory-overlay { @@ -2054,8 +1931,8 @@ body { background: rgba(15, 15, 26, 0.75); border: 2px solid #5F71C5; border-radius: 3px; - width: 900px; - height: 640px; + width: 1100px; + height: 780px; display: flex; flex-direction: column; overflow: hidden; @@ -2227,13 +2104,6 @@ body { border-bottom: 1px solid #1f2028; } -.location-surrounding-shop { - color: #e0e0e0; - font-size: 13px; - padding: 10px 16px; - border-bottom: 1px solid #1f2028; -} - .location-surrounding-corpse { color: #6b7280; font-size: 13px; @@ -2247,6 +2117,11 @@ body { gap: 12px; padding: 10px 16px; border-bottom: 1px solid #1f2028; + cursor: pointer; +} + +.location-npc-row:hover { + background: rgba(95, 113, 197, 0.15); } .location-npc-name { @@ -2256,37 +2131,30 @@ body { flex: 1; } -.location-action-btn { - background: #5F71C5; - color: #fff; - border: none; - border-radius: 1px; - padding: 8px 20px; - cursor: pointer; - font-size: 14px; +.location-npc-arrow { + color: #5F71C5; + font-size: 18px; font-weight: bold; } -.location-action-btn:hover { - background: #5F71C5; +.location-back { + background: none; + border: none; + color: #5F71C5; + font-size: 16px; + cursor: pointer; + padding: 0; } -.action-modal { - background: rgba(15, 15, 26, 0.75); - border: 2px solid #5F71C5; - border-radius: 3px; - width: 600px; - height: 500px; - max-width: 90vw; - max-height: 80vh; - position: relative; +.location-back:hover { + color: #e0e0e0; } .action-body { display: flex; align-items: center; justify-content: center; - height: 100%; + flex: 1; } .action-fight-btn { @@ -2362,3 +2230,277 @@ body { .arm-choice-cancel:hover { color: #e0e0e0; } + +.menu-screen { + width: 100vw; + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: #0f0f1a; + background-image: radial-gradient(ellipse at 50% 30%, #1a1a3a 0%, #0f0f1a 70%); +} + +.menu-content { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} + +.menu-title { + font-size: 64px; + font-weight: bold; + color: #fbbf24; + letter-spacing: 8px; + text-transform: uppercase; + margin: 0; +} + +.menu-subtitle { + font-size: 18px; + color: #6b7280; + margin: 0 0 24px; +} + +.menu-btn { + background: #5F71C5; + color: #fff; + border: none; + border-radius: 3px; + padding: 14px 48px; + cursor: pointer; + font-size: 18px; + font-weight: bold; + transition: background 0.15s; +} + +.menu-btn:hover { + background: #4a5cb0; +} + +.char-create-panel { + background: #1a1a2e; + border: 1px solid #2d2d44; + border-radius: 8px; + padding: 32px 40px; + display: flex; + flex-direction: column; + gap: 20px; + min-width: 400px; +} + +.char-create-title { + font-size: 28px; + font-weight: bold; + color: #fbbf24; + text-align: center; + margin: 0 0 8px; + letter-spacing: 3px; +} + +.char-create-field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.char-create-field label { + color: #9ca3af; + font-size: 13px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 1px; +} + +.char-create-input { + background: #0f0f1a; + border: 1px solid #2d2d44; + border-radius: 3px; + padding: 10px 14px; + color: #e0e0e0; + font-size: 16px; + outline: none; +} + +.char-create-input:focus { + border-color: #5F71C5; +} + +.char-create-options { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.char-create-option { + background: #0f0f1a; + border: 1px solid #2d2d44; + border-radius: 3px; + padding: 8px 14px; + color: #9ca3af; + cursor: pointer; + font-size: 13px; + transition: all 0.15s; +} + +.char-create-option:hover { + border-color: #5F71C5; + color: #e0e0e0; +} + +.char-create-option.selected { + border-color: #5F71C5; + background: #2a2a44; + color: #fff; +} + +.char-create-color { + width: 48px; + height: 48px; + padding: 2px; + border: 1px solid #2d2d44; + border-radius: 4px; + background: #0f0f1a; + cursor: pointer; +} + +/* ═══════════════════════════════════════ TRADE WINDOW */ +.trade-overlay { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} +.trade-panel { + background: #1e1e2e; + border: 1px solid #4a4a6a; + border-radius: 8px; + padding: 20px; + min-width: 600px; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; + position: relative; +} +.trade-close { + position: absolute; + top: 8px; right: 10px; + background: none; + border: none; + color: #9ca3af; + font-size: 18px; + cursor: pointer; +} +.trade-close:hover { color: #fff; } +.trade-title { + font-size: 18px; + font-weight: bold; + color: #e0e0e0; + margin-bottom: 16px; + text-align: center; +} +.trade-layout { + display: flex; + gap: 16px; + flex: 1; + overflow: hidden; +} +.trade-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +.trade-col-header { + font-size: 14px; + font-weight: 600; + color: #9ca3af; + margin-bottom: 8px; + padding-bottom: 4px; + border-bottom: 1px solid #2d2d44; +} +.trade-items { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + flex: 1; + max-height: 40vh; +} +.trade-item { + display: flex; + align-items: center; + justify-content: space-between; + background: #2a2a3e; + border-radius: 4px; + padding: 6px 10px; + gap: 8px; +} +.trade-item-name { + font-size: 13px; + color: #e0e0e0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trade-qty-controls { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.trade-qty-btn { + width: 24px; + height: 24px; + border: 1px solid #4a4a6a; + border-radius: 3px; + background: #1e1e2e; + color: #e0e0e0; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.trade-qty-btn:hover { + background: #3a3a5e; +} +.trade-qty { + font-size: 13px; + color: #9ca3af; + min-width: 20px; + text-align: center; +} +.trade-bar { + margin-top: 12px; + padding: 10px 12px; + background: #2a2a3e; + border-radius: 4px; + text-align: center; + font-size: 14px; + color: #9ca3af; +} +.trade-give-val { color: #f59e0b; font-weight: bold; } +.trade-take-val { color: #10b981; font-weight: bold; } +.trade-propose-btn { + margin-top: 10px; + width: 100%; + padding: 10px; + background: #374151; + border: 1px solid #5F71C5; + border-radius: 4px; + color: #e0e0e0; + font-size: 15px; + cursor: pointer; + transition: background 0.15s; +} +.trade-propose-btn:hover { + background: #4b5563; +} diff --git a/src/App.jsx b/src/App.jsx index 0f39000..2409394 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { useState, useRef, useCallback, useEffect, Fragment } from 'react' import './App.css' import maleBody from './assets/body/malebody.png' +import pistolShotMp3 from './assets/pistolshot.mp3' // ───────────────────────────────────────────────────────────────────────────── // WORLD DATA @@ -21,7 +22,7 @@ const connections = [ ] const characters = [ - { id: 'player', name: 'You', location: 'A', size: 1 }, + { 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 }, @@ -29,25 +30,12 @@ const characters = [ { 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 }, -] - -const shops = [ - { - id: 'shop_e', locationId: 'E', name: 'Epsilon Market', - items: [ - { name: 'Steel Sword', price: 80, weaponId: 'steel_sword' }, - { name: 'Iron Shield', price: 50, weaponId: 'iron_shield' }, - { name: 'Dagger', price: 30, weaponId: 'dagger' }, - { name: 'Pistol Ammo', price: 5, itemId: 'pistol_ammo' }, - { name: 'Bouncing Ammo', price: 8, itemId: 'bouncing_ammo' }, - ], - }, + { id: 'd5', name: 'Cinder', location: 'D', money: 22, inventory: ['pistol_ammo'], size: 1 }, ] 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, skills: [{ name: 'Shoot', mult: 1.0, damageType: 'pierce', range: 200 }, { name: 'Reload', mult: 0, damageType: 'reload', range: 0 }] }, + { id: 'pistol', name: 'Pistol', damage: 70, condition: 100, maxCondition: 100, passives: ['Ranged'], weight: 3, speed: 12, 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 }] }, @@ -78,11 +66,33 @@ const MAX_BODY = { head: 100, torso: 100, leftArm: 100, rightArm: 100, leftLeg const MAX_HP = Object.values(MAX_BODY).reduce((a, b) => a + b, 0) const MAX_WEIGHT = 50 -const MAP_BLOCKS = [ - { x: 30, y: 20, w: 20, h: 40 }, - { x: 100, y: 10, w: 25, h: 35 }, - { x: 15, y: 90, w: 35, h: 20 }, -] +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 } + const blocks = [] + const num = 3 + Math.floor(rng() * 3) + 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)) + blocks.push({ x, y, w, h }) + } + return blocks +} + +const ITEM_VALUES = { + pistol_ammo: 5, bouncing_ammo: 8, steel_sword: 80, iron_shield: 50, dagger: 30, + rusty_sword: 40, pistol: 100, warhammer: 90, +} +function getItemValue(id) { + if (ITEM_VALUES[id] !== undefined) return ITEM_VALUES[id] + if (weaponMap[id]) return weaponMap[id].damage + return 1 +} const INJURY_DECAY = { cut: 0.3, laceration: 0.6, stab: 0.8 } const TURN_SPEED = 6 @@ -173,14 +183,14 @@ function calcIntegrity(integrity, injuries, deltaTime = 1) { // ───────────────────────────────────────────────────────────────────────────── /** - * Move an actor toward a target, respecting MAP_BLOCKS. + * Move an actor toward a target, respecting the given blocks. * Returns the new {x, y} position. * @param {{x:number,y:number}} from * @param {{x:number,y:number}} to * @param {number} speed – units per step * @returns {{x:number,y:number}} */ -function computeMove(from, to, speed) { +function computeMove(from, to, speed, blocks) { const dx = to.x - from.x const dy = to.y - from.y const dist = Math.sqrt(dx * dx + dy * dy) @@ -188,13 +198,13 @@ function computeMove(from, to, speed) { const step = 0.5 const totalSteps = Math.ceil(speed / step) - let cx = from.x, cy = from.y + let cx = from.x, cy = from.y; const blk = blocks ?? [] for (let i = 0; i < totalSteps; i++) { const remaining = speed - i * step const thisStep = Math.min(step, remaining) const nx = cx + (dx / dist) * thisStep const ny = cy + (dy / dist) * thisStep - if (MAP_BLOCKS.some(b => rectCollides(nx, ny, b))) break + if (blk.some(b => rectCollides(nx, ny, b))) break cx = nx; cy = ny } return { x: cx, y: cy } @@ -203,18 +213,18 @@ function computeMove(from, to, speed) { /** * Compute a shove effect, returning the pushed position. */ -function computeShove(attackerPos, targetPos) { +function computeShove(attackerPos, targetPos, blocks) { const pushDist = 30 + Math.floor(Math.random() * 20) const dx = targetPos.x - attackerPos.x const dy = targetPos.y - attackerPos.y const dist = Math.sqrt(dx * dx + dy * dy) || 1 const nx = dx / dist, ny = dy / dist - let ex = targetPos.x, ey = targetPos.y + let ex = targetPos.x, ey = targetPos.y; const blk = blocks ?? [] const steps = Math.ceil(pushDist / 3) for (let i = 1; i <= steps; i++) { const sx = targetPos.x + nx * (pushDist * i / steps) const sy = targetPos.y + ny * (pushDist * i / steps) - if (MAP_BLOCKS.some(b => rectCollides(sx, sy, b))) break + if (blk.some(b => rectCollides(sx, sy, b))) break ex = sx; ey = sy } const actualPush = Math.sqrt((ex - targetPos.x) ** 2 + (ey - targetPos.y) ** 2) @@ -268,7 +278,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 }) { +function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player' }) { const base = weaponMap[weaponId] const isArm = weaponId === 'left_arm' || weaponId === 'right_arm' const skill = isArm @@ -276,7 +286,7 @@ 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: 'player', weaponId, skillName, enemyIndex } + return { type: 'shove_pending', actor, weaponId, skillName, enemyIndex, charId } } const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 @@ -286,8 +296,8 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp return { type: 'attack', - actor: 'player', - charId: 'player', + actor, + charId, enemyCharId: enemyId, weaponId, skillName, targetPart, enemyIndex, dmg, severity, injType, injLabel, @@ -302,50 +312,61 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp * Run enemy AI. Returns an array of raw action objects. * Uses the same computeMove / computeHit as the player. */ -function runEnemyAI(combat, playerParts, playerInjuries) { +function runEnemyAI(combat, playerParts, playerInjuries, deadThisTick) { const actions = [] - const playerChar = combat.characters[combat.playerEntityId] - const playerPos = playerChar.pos + 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 (cid === combat.playerEntityId) continue + if (partyIds.includes(cid) || (combat.npcIds ?? []).includes(cid)) continue const e = combat.characters[cid] if (isDead(e.integrity)) continue - const dist = Math.sqrt( - (playerPos.x - e.pos.x) ** 2 + (playerPos.y - e.pos.y) ** 2 - ) + // Find nearest alive party member for this enemy + 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 (!targetId) continue + const targetChar = combat.characters[targetId] // ── in range → attack - if (dist <= 15) { const weaponId = e.weapon ?? 'fists' const base = weaponMap[weaponId] - const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt' }])[ - Math.floor(Math.random() * (base?.skills?.length ?? 1)) - ] + 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 + 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 }) + } + continue + } + const skill = skills[Math.floor(Math.random() * skills.length)] const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions: {} }) const targetPart = randomPart() actions.push({ type: 'attack', actor: 'enemy', charId: cid, + targetCharId: targetId, targetPart, weaponId, skillName: skill.name, dmg, severity, injType, injLabel, attackerParts: { ...e.integrity }, attackerInjuries: { ...e.injuries }, - targetParts: { ...playerParts }, - targetInjuries: { ...playerInjuries }, + targetParts: { ...targetChar.integrity }, + targetInjuries: { ...targetChar.injuries }, }) - } else { - // ── out of range → move toward player - const speed = e.speed > 12 ? 10 : 5 - const newPos = computeMove(e.pos, playerPos, speed) - 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 }) - } - } } return actions @@ -376,6 +397,66 @@ function BodyImage({ body }) { ) } +// ───────────────────────────────────────────────────────────────────────────── +// CHARACTER CREATOR +// ───────────────────────────────────────────────────────────────────────────── + +function CharacterCreator({ onConfirm }) { + const [name, setName] = useState('John') + const [weaponId, setWeaponId] = useState('rusty_sword') + const [fillColor, setFillColor] = useState('#fbbf24') + const [locationId, setLocationId] = useState('A') + + const startWeapons = [ + { id: 'rusty_sword', name: 'Rusty Sword' }, + { id: 'pistol', name: 'Pistol' }, + { id: 'steel_sword', name: 'Steel Sword' }, + { id: 'iron_shield', name: 'Iron Shield' }, + { id: 'dagger', name: 'Dagger' }, + { id: 'fists', name: 'Fists only' }, + ] + + return ( +
+
+

CREATE CHARACTER

+ +
+ + setName(e.target.value)} className="char-create-input" maxLength={20} /> +
+ +
+ +
+ {startWeapons.map(w => ( + + ))} +
+
+ +
+ + setFillColor(e.target.value)} className="char-create-color" /> +
+ +
+ +
+ {locations.map(l => ( + + ))} +
+
+ + +
+
+ ) +} + // ───────────────────────────────────────────────────────────────────────────── // APP // ───────────────────────────────────────────────────────────────────────────── @@ -383,7 +464,7 @@ function BodyImage({ body }) { export default function App() { // ── world state const [player, setPlayer] = useState(createCharacter({ - id: 'player', name: 'You', age: 24, location: 'A', money: 50, + id: 'player', name: 'John', age: 24, location: 'A', money: 50, inventory: ['pistol_ammo', 'bouncing_ammo'], equipped: { left: 'pistol', right: 'rusty_sword' }, weaponConditions: { rusty_sword: 65, pistol: 100 }, @@ -391,9 +472,11 @@ export default function App() { weaponMagazines: { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] }, itemCounts: { pistol_ammo: 20, bouncing_ammo: 10 }, })) - const [gameTime, setGameTime] = useState({ day: 1, hour: 8 }) + const [gameTime, setGameTime] = useState({ day: 1, hour: 8, minute: 0 }) const [weather, setWeather] = useState('clear') const [defeatedNpcs, setDefeatedNpcs] = useState([]) + const [characterOverrides, setCharacterOverrides] = useState({}) + const liveCharacters = characters.map(c => ({ ...c, ...characterOverrides[c.id] })).filter(c => !defeatedNpcs.includes(c.id)) // ── ui state const [hovered, setHovered] = useState(null) @@ -403,20 +486,22 @@ export default function App() { const [travelAnim, setTravelAnim] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) const [showInventory, setShowInventory] = useState(false) - const [showWorldInfo, setShowWorldInfo] = useState(false) + const [showWorldInfo, setShowWorldInfo] = useState(false) + const [showWorldMap, setShowWorldMap] = useState(false) const [showSettings, setShowSettings] = useState(false) const [invTab, setInvTab] = useState('health') const [panelTarget, setPanelTarget] = useState('player') const [contextMenu, setContextMenu] = useState(null) - const [buyArmChoice, setBuyArmChoice] = useState(null) const [equipArmChoice, setEquipArmChoice] = useState(null) + const [showTrade, setShowTrade] = useState(null) + const [tradeTarget, setTradeTarget] = useState(null) + const [tradeGive, setTradeGive] = useState({}) + const [tradeTake, setTradeTake] = useState({}) const [passTimeOpen, setPassTimeOpen] = useState(false) const [passTimeHours, setPassTimeHours] = useState(1) const [skipAnim, setSkipAnim] = useState(null) const [zoomAnim, setZoomAnim] = useState(null) - const [showLocationModal,setShowLocationModal]= useState(null) - const [actionNpc, setActionNpc] = useState(null) - const [locationLog, setLocationLog] = useState([]) + // ── combat state const [combat, setCombat] = useState(null) @@ -426,10 +511,10 @@ export default function App() { // so the rAF loop sees it synchronously on the very next frame. // ── combat ui - const [expandedWeapon, setExpandedWeapon] = useState(null) - const [weaponsOpen, setWeaponsOpen] = useState(false) - const [itemsOpen, setItemsOpen] = useState(false) - const [movementOpen, setMovementOpen] = useState(false) + const [expandedWeapon, setExpandedWeapon] = useState([]) + const [weaponsOpen, setWeaponsOpen] = useState([]) + const [itemsOpen, setItemsOpen] = useState([]) + const [movementOpen, setMovementOpen] = useState([]) const [limbSelect, setLimbSelect] = useState(null) const [selectedTarget, setSelectedTarget] = useState(null) const [selectedSkill, setSelectedSkill] = useState(null) @@ -438,6 +523,7 @@ export default function App() { const [selectedEnemy, setSelectedEnemy] = useState(0) const [attackEnemy, setAttackEnemy] = useState(null) const [moveMode, setMoveMode] = useState(null) + const [currentActor, setCurrentActor] = useState(null) // ── animation state const [animations, setAnimations] = useState([]) @@ -450,7 +536,7 @@ export default function App() { // ── aiming mode (pistol skill selected, waiting for minimap click) const [aimMode, setAimMode] = useState(false) - // ── reload ammo picker removed; uses selectedSkill === 'Reload' inline + // ── reload ammo picker removed; uses selectedSkill === 'Load' inline // ── projectiles (modular projectile framework) const [projectiles, setProjectiles] = useState([]) @@ -462,6 +548,10 @@ export default function App() { const [fightRecord, setFightRecord] = useState(null) const [replayActive, setReplayActive] = useState(false) + // ── menu + const [showMenu, setShowMenu] = useState(true) + const [showCharCreate, setShowCharCreate] = useState(false) + // ── refs (live values for rAF callbacks) const combatRef = useRef(null); combatRef.current = combat const combatTimeRef = useRef(0); combatTimeRef.current = combatTime @@ -473,6 +563,7 @@ export default function App() { const [speedMult, setSpeedMult] = useState(1) 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()) // ── Universal character action/direction/position refs ── // Every character (player, enemies) uses the same ref structure. @@ -506,8 +597,7 @@ export default function App() { return `${MONTHS[m]} ${d}` } const playerHp = totalHp(player.integrity) - const shopAtLoc = shops.find(s => s.locationId === selected && s.locationId === player.location) - const liveCharacters= characters.filter(c => !defeatedNpcs.includes(c.id)) + // liveCharacters defined above with characterOverrides const connectedTo = (locId) => { const result = [] @@ -558,7 +648,7 @@ export default function App() { [PROJ_TYPES.BULLET]: { speed: 800, hitRadius: 10 }, } - function createProjectile(startPos, endPos, actor, weaponId, skillName, ammoType) { + function createProjectile(startPos, endPos, actor, weaponId, skillName, ammoType, charId) { const dx = endPos.x - startPos.x const dy = endPos.y - startPos.y const clickDist = Math.sqrt(dx * dx + dy * dy) || 1 @@ -567,6 +657,7 @@ export default function App() { id: `proj-${Date.now()}`, type: PROJ_TYPES.BULLET, actor, + charId: charId ?? 'player', startPos: { ...startPos }, endPos: { ...endPos }, pos: { ...startPos }, @@ -603,9 +694,15 @@ export default function App() { const cx = p.pos.x + p.dir.x * moveDist * t const cy = p.pos.y + p.dir.y * moveDist * t - if (MAP_BLOCKS.some(b => rectCollides(cx, cy, b))) { + const curBlocks = cc.blocks ?? [] + const hitBlock = curBlocks.find(b => rectCollides(cx, cy, b)) + if (hitBlock) { if (p.ammoType === 'bouncing_ammo') { - if (Math.abs(p.dir.x) > Math.abs(p.dir.y)) { + const dl = cx - hitBlock.x + const dr = (hitBlock.x + hitBlock.w) - cx + const dt = cy - hitBlock.y + const db = (hitBlock.y + hitBlock.h) - cy + if (Math.min(dl, dr) < Math.min(dt, db)) { p.dir.x = -p.dir.x } else { p.dir.y = -p.dir.y @@ -621,7 +718,7 @@ export default function App() { } for (const cid of Object.keys(cc.characters)) { - if (cid === cc.playerEntityId) continue + if (cid === p.charId) continue const e = cc.characters[cid] if (!e || isDead(e.integrity)) continue const ePos = characterPositionsRef.current[cid] ?? e.pos @@ -651,9 +748,9 @@ export default function App() { */ function createWeaponStrikeAnim(action, cc) { if (!cc) return null - const isPlayer = action.actor === 'player' - const attackerId = isPlayer ? cc.playerEntityId : action.charId - const targetId = isPlayer ? action.enemyCharId : cc.playerEntityId + const isPlayerSide = action.actor === 'player' || action.actor === 'party' + const attackerId = action.charId + const targetId = action.targetCharId ?? (isPlayerSide ? action.enemyCharId : (cc.partyIds?.[0] ?? cc.playerEntityId)) const attacker = cc.characters[attackerId] const target = cc.characters[targetId] const startPos = attacker ? { ...attacker.pos } : { x: 20, y: 75 } @@ -688,6 +785,8 @@ export default function App() { attackerInjuries: action.attackerInjuries, targetParts: action.targetParts, targetInjuries: action.targetInjuries, + enemyCharId: action.enemyCharId, + targetCharId: action.targetCharId, }) } @@ -793,16 +892,20 @@ export default function App() { } const cc = combatRef.current - const enemy = cc?.characters[charId] - if (!enemy || isDead(enemy.integrity)) return + const target = cc?.characters[charId] + if (!target || isDead(target.integrity)) return const skill = weaponMap.pistol?.skills?.[0] if (!skill) return + const shooterChar = cc?.characters[proj.charId] + const shooterConditions = proj.charId === 'player' ? playerRef.current.weaponConditions : (shooterChar?.weaponConditions ?? {}) + const shooterName = proj.charId === 'player' ? 'You' : (shooterChar?.name ?? 'Someone') + const { dmg, severity, injType, injLabel } = computeHit({ weaponId: 'pistol', skill, - weaponConditions: playerRef.current.weaponConditions, + weaponConditions: shooterConditions, bothMult: 1.0, }) @@ -824,11 +927,12 @@ export default function App() { characters[charId] = e const log = dmg > 0 - ? [`You shoot ${e.name} in the ${bodyPartLabels[part] ?? part} for ${dmg} damage! ${injLabel} (severity ${severity}).`] - : ['Your shot glances off harmlessly.'] + ? [`${shooterName} shoots ${e.name} in the ${bodyPartLabels[part] ?? part} for ${dmg} damage! ${injLabel} (severity ${severity}).`] + : [`${shooterName}'s shot glances off harmlessly.`] if (isDead(integrity)) { - if (part === 'head') log.push(`You blow ${e.name}'s head off!`) + if (part === 'head') log.push(`${shooterName} blows ${e.name}'s head off!`) log.push(`${e.name} is defeated!`) + deadThisTickRef.current.add(charId) } return { ...c, characters, log: [...c.log, ...log] } }) @@ -838,9 +942,10 @@ export default function App() { /** Queue a shot toward a map coordinate — queues a shoot action. */ const queueShot = (tx, ty) => { if (!combat) return - const startPos = characterPositionsRef.current[combat.playerEntityId] ?? combat.characters[combat.playerEntityId].pos - characterActionsRef.current[combat.playerEntityId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 }) - setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null) + const actorId = currentActor ?? 'player' + const startPos = characterPositionsRef.current[actorId] ?? combat.characters[actorId]?.pos ?? combat.characters[combat.playerEntityId].pos + characterActionsRef.current[actorId] = new AnimAction({ type: 'shoot', startPos: { x: startPos.x, y: startPos.y }, endPos: { x: tx, y: ty }, elapsed: 0 }) + setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null); setCurrentActor(null) } /** @@ -901,12 +1006,23 @@ class SeqAction { } } +class ParallelAction { + constructor(data) { Object.assign(this, data) } + step(dt) { + let moveDone = false + for (const a of this.actions) { + if (a.step(dt) && a instanceof MoveAction) moveDone = true + } + return moveDone + } +} + class MoveAction { constructor(data) { Object.assign(this, data); this.cx = this.fromX; this.cy = this.fromY } step(dt) { - const steps = Math.max(1, Math.round(dt * 60)) + 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) + 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) @@ -919,7 +1035,8 @@ class MoveAction { function applyAnimHit(anim) { const liveCc = combatRef.current const attackerId = anim.charId - const targetId = anim.actor === 'player' ? anim.enemyCharId : liveCc?.playerEntityId + const isPlayerSide = anim.actor === 'player' || anim.actor === 'party' + const targetId = anim.targetCharId ?? (isPlayerSide ? anim.enemyCharId : (liveCc?.partyIds?.[0] ?? liveCc?.playerEntityId)) let inRange = false if (liveCc) { @@ -947,23 +1064,43 @@ class MoveAction { const dist = aPos && tPos ? Math.sqrt((aPos.x - tPos.x)**2 + (aPos.y - tPos.y)**2) : -1 const dist2 = aPos2 && tPos2 ? Math.sqrt((aPos2.x - tPos2.x)**2 + (aPos2.y - tPos2.y)**2) : -1 const range = getSkillRange(anim.weaponId, anim.skillName) - const msg = anim.actor === 'player' + const attackerName = liveCc?.characters[anim.charId]?.name ?? (anim.actor === 'player' ? 'You' : 'Enemy') + const msg = isPlayerSide ? `Your attack misses! (distance combatRef=${dist.toFixed(1)} refs=${dist2.toFixed(1)} range=${range})` - : `${liveCc?.characters[anim.charId]?.name ?? 'Enemy'}'s attack misses!` + : `${attackerName}'s attack misses!` setCombat(prev => ({ ...prev, log: [...prev.log, msg] })) return } setHitShake({ actor: anim.actor, charId: anim.charId, start: performance.now() }) - if (anim.actor === 'player') { + if (isPlayerSide) { const wid = anim.weaponId if (wid !== 'fists' && wid !== 'left_arm' && wid !== 'right_arm') { const base = weaponMap[wid] - setPlayer(p => ({ ...p, weaponConditions: { ...p.weaponConditions, [wid]: Math.max(0, (p.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } })) + if (anim.actor === 'player') { + setPlayer(p => ({ ...p, weaponConditions: { ...p.weaponConditions, [wid]: Math.max(0, (p.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } })) + } else { + setCombat(prev => { + const ch = prev.characters[anim.charId] + if (!ch) return prev + return { ...prev, characters: { ...prev.characters, [anim.charId]: { ...ch, weaponConditions: { ...ch.weaponConditions, [wid]: Math.max(0, (ch.weaponConditions[wid] ?? base.maxCondition) - (1 + Math.floor(Math.random() * 2))) } } } } + }) + } } - const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) + if (anim.actor === 'player') { + const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) + setPlayer(p => ({ ...p, integrity: decayedPlayer })) + setPlayer(p => ({ ...p, injuries: playerRef.current.injuries })) + } else { + setCombat(prev => { + const ch = prev.characters[anim.charId] + if (!ch) return prev + const decayed = calcIntegrity(ch.integrity, ch.injuries, ANIM_CFG[anim.type].hitAt) + return { ...prev, characters: { ...prev.characters, [anim.charId]: { ...ch, integrity: decayed } } } + }) + } if (liveCc && anim.enemyCharId) { const enemy = liveCc.characters[anim.enemyCharId] @@ -974,13 +1111,15 @@ class MoveAction { const newInjuries = prevHp > 0 && anim.severity > 0 ? applyInjury(enemy.injuries, anim.targetPart, anim.injType, anim.severity) : enemy.injuries + const attackerName = liveCc.characters[anim.charId]?.name ?? 'You' const log = anim.dmg > 0 - ? [`You ${anim.skillName?.toLowerCase() ?? 'attack'} ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] + ? [`${attackerName} ${anim.skillName?.toLowerCase() ?? 'attack'}s ${enemy.name}'s ${bodyPartLabels[anim.targetPart]} — ${anim.injLabel} (severity ${anim.severity}, -${anim.dmg} integrity).`] : [] if (isDead(hitEnemy)) { - if (anim.targetPart === 'head') log.push(`You crush ${enemy.name}'s head!`) + if (anim.targetPart === 'head') log.push(`${attackerName} crushes ${enemy.name}'s head!`) log.push(`${enemy.name} is defeated!`) + deadThisTickRef.current.add(anim.enemyCharId) } setCombat(prev => ({ @@ -990,20 +1129,24 @@ class MoveAction { })) } } - - setPlayer(p => ({ ...p, integrity: decayedPlayer })) - setPlayer(p => ({ ...p, injuries: playerRef.current.injuries })) } else { const currentEnemy = liveCc?.characters[anim.charId] const decayedEnemy = currentEnemy ? calcIntegrity(currentEnemy.integrity, currentEnemy.injuries, ANIM_CFG[anim.type].hitAt) : calcIntegrity(anim.attackerParts, anim.attackerInjuries, ANIM_CFG[anim.type].hitAt) - const decayedPlayer = calcIntegrity(playerRef.current.integrity, playerRef.current.injuries, ANIM_CFG[anim.type].hitAt) - const prevHp = decayedPlayer[anim.targetPart] - const hitPlayer = { ...decayedPlayer, [anim.targetPart]: Math.max(0, prevHp - anim.dmg) } + + // Find target party member + const targetCharId = anim.targetCharId ?? (combat?.partyIds?.[0] ?? 'player') + const targetChar = liveCc?.characters[targetCharId] + const targetIntegrity = targetChar?.integrity ?? playerRef.current.integrity + const targetInjuries = targetChar?.injuries ?? playerRef.current.injuries + + 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 newInjuries = prevHp > 0 && anim.severity > 0 - ? applyInjury(playerRef.current.injuries, anim.targetPart, anim.injType, anim.severity) - : playerRef.current.injuries + ? applyInjury(targetInjuries, anim.targetPart, anim.injType, anim.severity) + : targetInjuries if (liveCc) { const enemy = liveCc.characters[anim.charId] @@ -1011,14 +1154,23 @@ class MoveAction { setCombat(prev => ({ ...prev, - characters: { ...prev.characters, [anim.charId]: { ...enemy, integrity: decayedEnemy } }, + characters: { + ...prev.characters, + [anim.charId]: { ...enemy, integrity: decayedEnemy }, + [targetCharId]: { ...prev.characters[targetCharId], integrity: hitTarget, injuries: newInjuries }, + }, log: anim.dmg > 0 - ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on your ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] + ? [...prev.log, `${enemyName} lands a ${anim.injLabel} on ${targetChar?.name ?? 'your'} ${bodyPartLabels[anim.targetPart]} (severity ${anim.severity}, integrity -${anim.dmg}).`] : prev.log, })) } - setPlayer(p => ({ ...p, integrity: hitPlayer, injuries: newInjuries })) + if (isDead(hitTarget)) { + deadThisTickRef.current.add(targetCharId) + } + if (targetCharId === 'player') { + setPlayer(p => ({ ...p, integrity: hitTarget, injuries: newInjuries })) + } } } @@ -1069,27 +1221,35 @@ class MoveAction { // ── Auto timer on busy state transitions; manual toggle via spacebar ── { - const _pId = combatRef.current?.playerEntityId - const _busy = _pId ? characterActionsRef.current[_pId] !== null : false + const cc = combatRef.current + const partyIds = cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean) + const allBusy = partyIds.length > 0 && partyIds.every(cid => { + const ch = cc.characters[cid] + return isDead(ch?.integrity) || characterActionsRef.current[cid] != null + }) const wasBusy = prevBusyRef.current - prevBusyRef.current = _busy - setBusy(_busy) + prevBusyRef.current = allBusy + setBusy(allBusy) - // Auto-start when player queues an action - if (_busy && !wasBusy && !timerActiveRef.current) { + if (allBusy && !wasBusy && !timerActiveRef.current && partyIds.some(cid => !isDead(cc.characters[cid]?.integrity))) { timerActiveRef.current = true setTimerRunning(true) } - // Auto-stop when player's action completes - if (!_busy && wasBusy && timerActiveRef.current) { + if (!allBusy && wasBusy && timerActiveRef.current) { timerActiveRef.current = false setTimerRunning(false) } } - // ── Stop timer if player dies ── - if (timerActiveRef.current && isDead(playerRef.current.integrity)) { - stopTimer() + // ── Stop timer if all party members die ── + { + 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))) { + stopTimer() + } + } } // dt is zero when the timer is off — animations don't play @@ -1103,8 +1263,19 @@ class MoveAction { const dt = rawDt * speedRef.current lastTime = now + deadThisTickRef.current = new Set() + setCombatTime(t => t + dt) setDisplayTime(t => t + dt) + setGameTime(gt => { + let minute = (gt.minute ?? 0) + dt / 60 + let extraH = Math.floor(minute / 60) + minute = minute % 60 + let h = gt.hour + extraH + let d = gt.day + if (h >= 24) { d += Math.floor(h / 24); h = h % 24 } + return { day: d, hour: h, minute } + }) // ── Build fresh combat snapshot with live positions ── const activeAnims = [] @@ -1123,6 +1294,13 @@ 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)) { + if (characterActionsRef.current[cid]) { + characterActionsRef.current[cid] = null + } + continue + } const rawAnim = characterActionsRef.current[cid] if (!rawAnim) continue @@ -1135,65 +1313,91 @@ class MoveAction { const done = rawAnim.step(dt) if (ANIM_CFG[rawAnim.type] && rawAnim.elapsed >= ANIM_CFG[rawAnim.type].hitAt && !rawAnim.hitFired) { rawAnim.hitFired = true - if (rawAnim.type === 'shoot') { - const pp = characterPositionsRef.current[cid] ?? cc.characters[cid]?.pos - const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } - const offset = 5 - const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } - if (cid === 'player' && magazineRef.current.pistol.length > 0) { - const bullet = magazineRef.current.pistol[0] - const newMag = { pistol: magazineRef.current.pistol.slice(1) } - magazineRef.current = newMag - setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) - const ddx = rawAnim.endPos.x - spawnPos.x - const ddy = rawAnim.endPos.y - spawnPos.y - const d = Math.sqrt(ddx * ddx + ddy * ddy) || 1 - const dir = { x: ddx / d, y: ddy / d } - const facing = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } - const dot = Math.max(-1, Math.min(1, facing.dx * dir.x + facing.dy * dir.y)) - const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) - const deviation = (Math.random() * 2 - 1) * maxDeviation - const cos = Math.cos(deviation), sin = Math.sin(deviation) - const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos } - const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y } - const ammoType = bullet.ammoType - setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, 'player', 'pistol', 'Shoot', ammoType)]) - } else if (cid === 'player') { - setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) - } + if (rawAnim.type === 'shoot') { + const pp = characterPositionsRef.current[cid] ?? cc.characters[cid]?.pos + const dir = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } + const offset = 5 + const spawnPos = { x: pp.x + dir.dx * offset, y: pp.y + dir.dy * offset } + const actor = cid === 'player' ? 'player' : 'party' + const charMag = cid === 'player' ? magazineRef.current : (cc.characters[cid]?.weaponMagazines ?? {}) + if (charMag.pistol?.length > 0) { + const bullet = charMag.pistol[0] + const newMag = { pistol: charMag.pistol.slice(1) } + if (cid === 'player') { + magazineRef.current = newMag + setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag } })) + } else { + setCombat(prev => ({ + ...prev, + characters: { + ...prev.characters, + [cid]: { ...prev.characters[cid], weaponMagazines: { ...prev.characters[cid].weaponMagazines, ...newMag } }, + }, + })) + } + const ddx = rawAnim.endPos.x - spawnPos.x + const ddy = rawAnim.endPos.y - spawnPos.y + const d = Math.sqrt(ddx * ddx + ddy * ddy) || 1 + const dir = { x: ddx / d, y: ddy / d } + const facing = characterDirsRef.current[cid] ?? { dx: 0, dy: 1 } + const dot = Math.max(-1, Math.min(1, facing.dx * dir.x + facing.dy * dir.y)) + const maxDeviation = (1 - dot) / 2 * (Math.PI / 4) + const deviation = (Math.random() * 2 - 1) * maxDeviation + const cos = Math.cos(deviation), sin = Math.sin(deviation) + const spreadDir = { x: dir.x * cos - dir.y * sin, y: dir.x * sin + dir.y * cos } + const finalDir = { x: isNaN(spreadDir.x) ? dir.x : spreadDir.x, y: isNaN(spreadDir.y) ? dir.y : spreadDir.y } + const ammoType = bullet.ammoType + setProjectiles(prev => [...prev, createProjectile(spawnPos, { x: spawnPos.x + finalDir.x * d, y: spawnPos.y + finalDir.y * d }, actor, 'pistol', 'Shoot', ammoType, cid)]) + new Audio(pistolShotMp3).play().catch(() => {}) + } else if (cid === 'player') { + setCombat(c => ({ ...c, log: [...c.log, 'Click! The pistol is empty.'] })) + } else { + setCombat(c => ({ ...c, log: [...c.log, `${cc.characters[cid]?.name ?? 'Party member'} clicks — the pistol is empty.`] })) + } } else { applyAnimHit(rawAnim) } } if (done) { // ── Enemy move → attack transition ── - if (rawAnim.type === 'move' && cid !== playerCharId) { - const e = fresh?.characters[cid] ?? cc.characters[cid] - const finalPos = { x: rawAnim.toX, y: rawAnim.toY } - if (e && !isDead(e.integrity)) { - const playerChar = (fresh ?? cc).characters[playerCharId] - const dist = playerChar ? Math.sqrt( - (playerChar.pos.x - finalPos.x) ** 2 + (playerChar.pos.y - finalPos.y) ** 2 - ) : 999 - const wpn = e.weapon ?? 'fists' - const base = weaponMap[wpn] - const skill = (base?.skills ?? [{ name: 'Attack', mult: 1.0, damageType: 'blunt', range: 15 }])[ - Math.floor(Math.random() * (base?.skills?.length ?? 1)) - ] - if (dist <= (skill.range ?? 15)) { - const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) - const targetPart = randomPart() - const action = { - type: 'attack', actor: 'enemy', charId: cid, targetPart, - weaponId: wpn, skillName: skill.name, - dmg, severity, injType, injLabel, - attackerParts: { ...e.integrity }, - attackerInjuries: { ...e.injuries }, - targetParts: { ...playerRef.current.integrity }, - targetInjuries: { ...playerRef.current.injuries }, + // 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 } + 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) + if (d < minDist) { minDist = d; targetId = pid } + } + if (targetId) { + const targetChar = (fresh ?? cc).characters[targetId] + const wpn = e.weapon ?? 'fists' + const base = weaponMap[wpn] + 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) { + const skill = skills[Math.floor(Math.random() * skills.length)] + const { dmg, severity, injType, injLabel } = computeHit({ weaponId: wpn, skill, weaponConditions: {} }) + const targetPart = randomPart() + const action = { + type: 'attack', actor: 'enemy', charId: cid, + targetCharId: targetId, + targetPart, + weaponId: wpn, skillName: skill.name, + dmg, severity, injType, injLabel, + attackerParts: { ...e.integrity }, + attackerInjuries: { ...e.injuries }, + targetParts: { ...targetChar.integrity }, + targetInjuries: { ...targetChar.injuries }, + } + characterActionsRef.current[cid] = createWeaponStrikeAnim(action, fresh ?? cc) + continue } - characterActionsRef.current[cid] = createWeaponStrikeAnim(action, fresh ?? cc) - continue } } } @@ -1208,7 +1412,7 @@ class MoveAction { // ── Run enemy AI every frame for idle enemies ── if (fresh) { - const actions = runEnemyAI(fresh, playerRef.current.integrity, playerRef.current.injuries) + 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]) { characterActionsRef.current[act.charId] = createWeaponStrikeAnim(act, fresh) @@ -1216,7 +1420,7 @@ class MoveAction { 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 } - characterActionsRef.current[act.charId] = new SeqAction({ + characterActionsRef.current[act.charId] = new ParallelAction({ type: 'move', actions: [ new TurnAction({ @@ -1226,6 +1430,7 @@ class MoveAction { }), new MoveAction({ ...act, + blocks: fresh.blocks, setPos: p => { setCombat(c => ({ ...c, @@ -1262,7 +1467,7 @@ class MoveAction { const frame = requestAnimationFrame(() => { const progress = Math.min(1, (performance.now() - travelAnim.startTs) / 1000) setTravelAnim(a => ({ ...a, progress })) - if (progress >= 1) { setPlayer(p => ({ ...p, location: travelAnim.toId })); setTravelAnim(null) } + if (progress >= 1) { setPlayer(p => ({ ...p, location: travelAnim.toId })); setTravelAnim(null); enterLocation(travelAnim.toId) } }) return () => cancelAnimationFrame(frame) }, [travelAnim]) @@ -1273,7 +1478,12 @@ class MoveAction { useEffect(() => { if (!combat || replayActive) return - if (isDead(player.integrity) || !(combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity))) endFight() + 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() }, [combat, player.integrity, playerHp]) // ───────────────────────────────────────────────────────────────────────── @@ -1314,7 +1524,7 @@ class MoveAction { useEffect(() => { if (combat) return - const id = setInterval(() => setGameTime(gt => { let h = gt.hour + 1, d = gt.day; if (h >= 24) { h = 0; d++ } return { day: d, hour: h } }), 5000) + const id = setInterval(() => setGameTime(gt => { let h = gt.hour + 1, d = gt.day; if (h >= 24) { h = 0; d++ } return { day: d, hour: h, minute: 0 } }), 5000) return () => clearInterval(id) }, [combat]) @@ -1392,13 +1602,18 @@ class MoveAction { } const startFight = (npcList) => { - const list = npcList ?? (() => { - const npc = liveCharacters.find(c => c.id === actionNpc) - return npc ? [npc] : [] - })() + const list = npcList ?? [] if (list.length === 0) return const enemies = list.map((npc, i) => makeEnemy(npc, i)) const characters = {} + + // Find party members (player + allies at same location) + const partyCharIds = ['player'] + const livePartyChars = liveCharacters.filter(c => c.id !== 'player' && c.location === player.location && !isDead(c.integrity) && c.equipped) + for (const pc of livePartyChars) { + partyCharIds.push(pc.id) + } + characters['player'] = createCharacter({ id: 'player', name: player.name, pos: { x: 20, y: 75 }, @@ -1409,6 +1624,21 @@ class MoveAction { weaponMagazines: { ...player.weaponMagazines }, itemCounts: { ...player.itemCounts }, }) + for (const pc of livePartyChars) { + characters[pc.id] = createCharacter({ + id: pc.id, name: pc.name, + pos: { x: 30 + partyCharIds.indexOf(pc.id) * 20, y: 80 }, + integrity: freshBody(), + injuries: freshInjuries(), + equipped: { ...(pc.equipped ?? { left: 'fists', right: 'fists' }) }, + weaponConditions: { ...(pc.weaponConditions ?? {}) }, + weaponMagazines: { ...(pc.weaponMagazines ?? {}) }, + itemCounts: { ...(pc.itemCounts ?? {}) }, + inventory: [...(pc.inventory ?? [])], + size: pc.size ?? 1, + fillColor: pc.fillColor, + }) + } for (const e of enemies) { const cid = e.id characters[cid] = { ...e, pos: { ...e.pos } } @@ -1416,9 +1646,11 @@ class MoveAction { 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(player.location), } setCombat(initCombat) stopTimer(); setBusy(false); prevBusyRef.current = false @@ -1431,8 +1663,8 @@ class MoveAction { characterPositionsRef.current[cid] = { ...characters[cid].pos } characterDirsRef.current[cid] = { dx: 0, dy: 1 } } - setSelectedEnemy(0); setReplayActive(false) - setExpandedWeapon('ROOT'); setAnimations([]); setHitShake(null); setMoveMode(null) + setSelectedEnemy(0); setReplayActive(false); setCurrentActor(null) + setExpandedWeapon(['player']); setAnimations([]); setHitShake(null); setMoveMode(null) recordingRef.current = { initialCombat: JSON.parse(JSON.stringify(initCombat)), frames: [{ time: 0, combat: JSON.parse(JSON.stringify(initCombat)), @@ -1442,6 +1674,80 @@ class MoveAction { } } + /** Enter a location — creates a combat view with all NPCs present as non-hostile */ + const enterLocation = (locationId) => { + const characters = {} + + characters['player'] = createCharacter({ + id: 'player', name: player.name, + pos: { x: 20, y: 75 }, + integrity: { ...player.integrity }, + injuries: JSON.parse(JSON.stringify(player.injuries)), + equipped: { ...player.equipped }, + weaponConditions: { ...player.weaponConditions }, + weaponMagazines: { ...player.weaponMagazines }, + itemCounts: { ...player.itemCounts }, + }) + + 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 }, + injuries: JSON.parse(JSON.stringify(lc.injuries ?? player.injuries)), + equipped: { ...lc.equipped }, + weaponConditions: { ...lc.weaponConditions }, + weaponMagazines: { ...lc.weaponMagazines }, + itemCounts: { ...lc.itemCounts }, + inventory: [...(lc.inventory ?? [])], + fillColor: lc.fillColor, + }) + } else { + npcIds.push(lc.id) + characters[lc.id] = createCharacter({ + id: lc.id, name: lc.name, + pos: { x: xOff, y: 130 }, + integrity: freshBody(), + injuries: freshInjuries(), + equipped: { left: 'fists', right: 'fists' }, + weaponConditions: {}, + weaponMagazines: {}, + itemCounts: {}, + inventory: [...(lc.inventory ?? [])], + fillColor: lc.fillColor, + }) + } + xOff += 25 + } + + const locName = locMap[locationId]?.name ?? locationId + const initCombat = { + playerEntityId: 'player', + partyIds: partyCharIds, + npcIds, + characters, + enemyIds: [], + log: [`You arrive at ${locName}.`], + blocks: generateLocationBlocks(locationId), + } + setCombat(initCombat) + setCombatTime(0); setDisplayTime(0) + characterActionsRef.current = {} + characterPositionsRef.current = {} + for (const cid of Object.keys(characters)) { + characterPositionsRef.current[cid] = { ...characters[cid].pos } + } + 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) + } + const endFight = () => { if (recordingRef.current) { pushSnapshot(); setFightRecord(recordingRef.current); recordingRef.current = null } stopTimer() @@ -1451,6 +1757,35 @@ class MoveAction { 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])]) + + // Sync party member combat state back to world + const cc = combatRef.current + if (cc) { + setCharacterOverrides(prev => { + const next = { ...prev } + for (const pid of cc.partyIds ?? []) { + if (pid === 'player') continue + const combatChar = cc.characters[pid] + if (combatChar) { + next[pid] = { + weaponConditions: { ...combatChar.weaponConditions }, + itemCounts: { ...combatChar.itemCounts }, + weaponMagazines: { ...combatChar.weaponMagazines }, + inventory: [...(combatChar.inventory ?? [])], + integrity: { ...combatChar.integrity }, + injuries: JSON.parse(JSON.stringify(combatChar.injuries)), + } + } + } + return next + }) + for (const pid of cc.partyIds ?? []) { + if (pid === 'player') continue + if (isDead(cc.characters[pid]?.integrity)) { + setDefeatedNpcs(prev => [...new Set([...prev, pid])]) + } + } + } } } @@ -1460,9 +1795,11 @@ class MoveAction { setCombat(c => ({ ...c, log: [...c.log, `DBG: early return limbSelect=${limbSelect} target=${selectedTarget}`] })) return } + const actorId = currentActor ?? 'player' const cc = combatRef.current if (!cc) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: no combat'] })); return } - if (isDead(playerRef.current.integrity)) { setCombat(c => ({ ...c, log: [...c.log, 'DBG: player dead'] })); 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] @@ -1472,9 +1809,10 @@ class MoveAction { } const weaponId = limbSelect - if (!canReach(cc.characters['player'].pos, enemy.pos, weaponId, selectedSkill)) { + const actorPos = characterPositionsRef.current[actorId] ?? actorChar.pos + if (!canReach(actorPos, enemy.pos, weaponId, selectedSkill)) { const d = Math.sqrt( - (cc.characters['player'].pos.x - enemy.pos.x)**2 + (cc.characters['player'].pos.y - enemy.pos.y)**2 + (actorPos.x - enemy.pos.x)**2 + (actorPos.y - enemy.pos.y)**2 ) setCombat(c => ({ ...c, log: [...c.log, `Too far (${d.toFixed(0)} > ${getSkillRange(weaponId, selectedSkill)})`] })) return @@ -1482,30 +1820,36 @@ class MoveAction { const action = buildPlayerAttack({ weaponId, skillName: selectedSkill, targetPart: selectedTarget, - enemyIndex: ei, equippedWeapons: player.equipped, weaponConditions: player.weaponConditions, - bodyParts: playerRef.current.integrity, bodyInjuries: playerRef.current.injuries, combat: cc, + enemyIndex: ei, + equippedWeapons: actorChar.equipped ?? { left: 'fists', right: 'fists' }, + weaponConditions: actorChar.weaponConditions ?? {}, + bodyParts: actorChar.integrity, + bodyInjuries: actorChar.injuries, + combat: cc, + actor: actorId === 'player' ? 'player' : 'party', + charId: actorId, }) if (action.type === 'shove_pending') { const enemy2 = cc.characters[action.enemyCharId] if (!enemy2 || isDead(enemy2.integrity)) return - const { pos, actualPush } = computeShove(cc.characters['player'].pos, enemy2.pos) + const { pos, actualPush } = computeShove(actorPos, enemy2.pos, fresh.blocks) setCombat(c => ({ ...c, characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, - log: [...c.log, `You shove ${enemy2.name} back ${Math.round(actualPush)} units!`], + log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`], })) - setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) return } const anim = createWeaponStrikeAnim(action, combatRef.current) - characterActionsRef.current['player'] = anim - setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + characterActionsRef.current[actorId] = anim + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) } const cancelAttack = () => { - setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) setAimMode(false) } @@ -1536,66 +1880,158 @@ class MoveAction { /** Execute a player move; queue it for execution. */ const startMove = (targetX, targetY) => { if (!combat) return - const curPos = characterPositionsRef.current['player'] ?? combat.characters['player'].pos + const actorId = currentActor ?? 'player' + const ch = combat.characters[actorId] + if (!ch) 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 = playerRef.current.maxSpeed ?? 10 + 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, - setPos: p => { - setCombat(c => ({ ...c, characters: { ...c.characters, player: { ...c.characters.player, pos: { ...p } } } })) - characterPositionsRef.current['player'] = p + 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 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['player'] = new SeqAction({ + characterActionsRef.current[actorId] = new ParallelAction({ actions: [ - new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }), + new TurnAction({ targetDir: dir, getDir: () => characterDirsRef.current[actorId] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current[actorId] = d } }), moveAction ] }) - setMoveMode(null) + setMoveMode(null); setCurrentActor(null) } /** Show ammo selection UI based on what's in inventory */ const startReload = () => { if (!combat) return - const cur = magazineRef.current.pistol.length + const actorId = currentActor ?? 'player' + const cc = combatRef.current + const actorChar = cc?.characters[actorId] + const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? {}) + const cur = mag.pistol?.length ?? 0 if (cur >= PISTOL_MAG) { setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already fully loaded.'] })) return } - const available = Object.entries(playerRef.current.itemCounts ?? {}) + const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) + const available = Object.entries(itemCounts) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) if (available.length === 0) { setCombat(c => ({ ...c, log: [...c.log, 'No ammo in inventory!'] })) return } - setSelectedSkill('Reload'); setAttackEnemy(null); setSelectedTarget(null) + setSelectedSkill('Load'); setAttackEnemy(null); setSelectedTarget(null) } /** Load one round of the given ammo type into the weapon */ const loadAmmoType = (ammoId) => { - const cur = magazineRef.current.pistol.length - const reserve = playerRef.current.itemCounts?.[ammoId] ?? 0 + const actorId = currentActor ?? 'player' + const cc = combatRef.current + const actorChar = cc?.characters[actorId] + const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] }) + const itemCounts = actorChar?.itemCounts ?? (actorId === 'player' ? playerRef.current.itemCounts : {}) + const cur = mag.pistol?.length ?? 0 + const reserve = itemCounts[ammoId] ?? 0 if (reserve <= 0) return - const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } - magazineRef.current = newMag const ammoName = itemMap[ammoId]?.name ?? ammoId - setPlayer(p => { - const newCount = (p.itemCounts[ammoId] ?? 0) - 1 - const counts = { ...p.itemCounts, [ammoId]: newCount } - if (newCount <= 0) delete counts[ammoId] - return { + if (actorId === 'player') { + const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } + magazineRef.current = newMag + setPlayer(p => { + const newCount = (p.itemCounts[ammoId] ?? 0) - 1 + const counts = { ...p.itemCounts, [ammoId]: newCount } + if (newCount <= 0) delete counts[ammoId] + return { + ...p, + weaponMagazines: { ...p.weaponMagazines, ...newMag }, + itemCounts: counts, + } + }) + setCombat(prev => { + const ch = prev.characters['player'] + const newCount = (ch?.itemCounts?.[ammoId] ?? 0) - 1 + const counts = { ...(ch?.itemCounts ?? {}), [ammoId]: newCount } + if (newCount <= 0) delete counts[ammoId] + return { + ...prev, + characters: { + ...prev.characters, + player: { ...ch, itemCounts: counts }, + }, + log: [...prev.log, `Loaded 1 round of ${ammoName} into the pistol.`], + } + }) + } else { + setCombat(prev => { + const ch = prev.characters[actorId] + if (!ch) return prev + const chMag = ch.weaponMagazines ?? { pistol: [] } + const newMag = { pistol: [...(chMag.pistol ?? []), { ammoType: ammoId }] } + const newCounts = { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) - 1 } + if (newCounts[ammoId] <= 0) delete newCounts[ammoId] + return { + ...prev, + characters: { + ...prev.characters, + [actorId]: { ...ch, weaponMagazines: { ...ch.weaponMagazines, ...newMag }, itemCounts: newCounts }, + }, + log: [...prev.log, `${ch.name ?? 'Party member'} loaded 1 round of ${ammoName} into the pistol.`], + } + }) + } + setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) + } + + const unloadPistol = () => { + if (!combat) return + const actorId = currentActor ?? 'player' + const cc = combatRef.current + const actorChar = cc?.characters[actorId] + const mag = actorId === 'player' ? magazineRef.current : (actorChar?.weaponMagazines ?? { pistol: [] }) + const cur = mag.pistol?.length ?? 0 + if (cur === 0) { + setCombat(c => ({ ...c, log: [...c.log, 'The pistol is already empty.'] })) + return + } + const top = mag.pistol[cur - 1] + const ammoId = top.ammoType + const ammoName = itemMap[ammoId]?.name ?? ammoId + if (actorId === 'player') { + const newMag = { pistol: mag.pistol.slice(0, -1) } + magazineRef.current = newMag + setPlayer(p => ({ ...p, weaponMagazines: { ...p.weaponMagazines, ...newMag }, - itemCounts: counts, - } - }) - setCombat(c => ({ ...c, log: [...c.log, `Loaded 1 round of ${ammoName} into the pistol.`] })) + itemCounts: { ...p.itemCounts, [ammoId]: (p.itemCounts[ammoId] ?? 0) + 1 }, + })) + setCombat(c => ({ ...c, log: [...c.log, `Unloaded 1 round of ${ammoName} from the pistol.`] })) + } else { + setCombat(prev => { + const ch = prev.characters[actorId] + if (!ch) return prev + const chMag = ch.weaponMagazines ?? { pistol: [] } + const newMag = { pistol: (chMag.pistol ?? []).slice(0, -1) } + return { + ...prev, + characters: { + ...prev.characters, + [actorId]: { + ...ch, + weaponMagazines: { ...ch.weaponMagazines, ...newMag }, + itemCounts: { ...ch.itemCounts, [ammoId]: (ch.itemCounts?.[ammoId] ?? 0) + 1 }, + }, + }, + log: [...prev.log, `${ch.name ?? 'Party member'} unloaded 1 round of ${ammoName} from the pistol.`], + } + }) + } setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) } @@ -1651,32 +2087,6 @@ class MoveAction { setTravelAnim({ from, to, fromId: player.location, toId: targetId, startTs: null, progress: 0 }) } - const buyItem = (item) => { - if (player.money < item.price) return - if (item.weaponId) { setBuyArmChoice({ item }) } - else if (item.itemId === 'pistol_ammo' || item.itemId === 'bouncing_ammo') { - const id = item.itemId - setPlayer(p => ({ - ...p, - money: p.money - item.price, - inventory: p.inventory.includes(id) ? p.inventory : [...p.inventory, id], - itemCounts: { ...p.itemCounts, [id]: (p.itemCounts?.[id] ?? 0) + 1 }, - })) - } - else { setPlayer(p => ({ ...p, money: p.money - item.price, inventory: [...p.inventory, item.itemId] })) } - } - - const confirmEquipArm = (arm) => { - const { item } = buyArmChoice - setPlayer(p => ({ - ...p, - money: p.money - item.price, - weaponConditions: { ...p.weaponConditions, [item.weaponId]: weaponMap[item.weaponId].maxCondition }, - equipped: arm === 'both' ? { left: item.weaponId, right: item.weaponId } : { ...p.equipped, [arm]: item.weaponId }, - })) - setBuyArmChoice(null) - } - const unequipWeapon = (arm) => { const wid = player.equipped[arm] if (wid === 'fists' || wid === 'left_arm' || wid === 'right_arm') return @@ -1709,6 +2119,7 @@ class MoveAction { const skipTotalHours = skipAnim !== null ? skipAnim.current : null const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour + const displayMinute = skipTotalHours !== null ? 0 : (gameTime.minute ?? 0) const dateString = getDateString(displayDay) // busy is React state, updated in the timer loop to reflect refs @@ -1717,13 +2128,90 @@ class MoveAction { // RENDER // ───────────────────────────────────────────────────────────────────────── - return ( -
setMousePos({ x: e.clientX, y: e.clientY })}> + const handleNewGame = () => { setShowMenu(false); setShowCharCreate(true) } - {/* ═══════════════════════════════════════════════════════ COMBAT PANEL */} - {combat && ( -
-
+ const canTrade = (charId) => { + if (!combat || charId === 'player') return true + const src = combat.characters?.player + const tgt = combat.characters?.[charId] + if (!src || !tgt) return true + const dx = src.x - tgt.x + const dy = src.y - tgt.y + return Math.sqrt(dx * dx + dy * dy) <= 35 + } + + const startTrade = (targetId) => { + setHoverInfo(null); setHoveredChar(null); setContextMenu(null) + setShowTrade(true) + setTradeTarget(targetId) + setTradeGive({}) + setTradeTake({}) + } + + if (showMenu) { + return ( +
+
+

CITYGAMIN

+

A game of survival and conquest

+ +
+
+ ) + } + + if (showCharCreate) { + return { + const { name, weaponId, fillColor, locationId } = opts + setPlayer(createCharacter({ + id: 'player', name, 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 } : {}, + size: 1.3, + weaponMagazines: weaponId === 'pistol' ? { pistol: [{ ammoType: 'pistol_ammo' }, { ammoType: 'pistol_ammo' }] } : {}, + itemCounts: weaponId === 'pistol' ? { pistol_ammo: 20, bouncing_ammo: 10 } : {}, + fillColor, + })) + setShowCharCreate(false) + setTimeout(() => enterLocation(locationId), 50) + }} /> + } + + return ( +
setMousePos({ x: e.clientX, y: e.clientY })} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}> + +
+
+ {combat && ( + + )} + + + + +
+
+ {(!combat || showWorldMap) && ( +
+ {(() => { + const d = displayDay; const h = displayHour; const min = displayMinute + const m = Math.floor(min); const s = Math.floor((min % 1) * 60) + return `${getDateString(d)} ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}` + })()} +
+ )} + {combat && !showWorldMap && (
- Time: {Math.floor(displayTime / 60)}:{String(Math.floor(displayTime) % 60).padStart(2,'0')}.{String(Math.floor((displayTime % 1) * 100)).padStart(2,'0')} + {(() => { + const min = gameTime.minute ?? 0 + const h = gameTime.hour + const m = Math.floor(min) + const s = Math.floor((min % 1) * 60) + const cs = Math.floor((((min % 1) * 60) % 1) * 100) + return `${getDateString(gameTime.day)} ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}.${String(cs).padStart(2,'0')}` + })()}
{[1, 0.5, 0.25, 0.1].map(s => (
-
+ )} +
+
-
- {/* ── left: action tree */} + {/* ═══════════════════════════════════════════════════════ COMBAT VIEW */} + {combat && !showWorldMap && ( + <> +
+ {/* ── left: action tree (per party member) */}
- {!isDead(player.integrity) && (combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity)) && ( -
-
-
setExpandedWeapon(expandedWeapon === 'ROOT' ? null : 'ROOT')}> - {expandedWeapon === 'ROOT' ? '▼' : '▶'} - { e.stopPropagation(); setShowInventory(true); setPanelTarget('player') }}>Player -
- {expandedWeapon === 'ROOT' && ( -
- {/* Weapons */} -
setWeaponsOpen(!weaponsOpen)}> - {weaponsOpen ? '▼' : '▶'} - Weapons -
- {weaponsOpen && ( -
- {(() => { - const lw = player.equipped.left, rw = player.equipped.right - const entries = [] - if (lw === 'fists') { - entries.push(
setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Left Arm
) - } else if (lw !== rw) { - entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Left: {weaponMap[lw].name}
) - } - if (rw === 'fists') { - entries.push(
setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Right Arm
) - } else if (lw !== rw) { - entries.push(
setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Right: {weaponMap[rw].name}
) - } - if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') { - entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Both: {weaponMap[lw].name}
) - } - if (lw === 'fists' && rw === 'fists') { - entries.push(
setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)} - onClick={() => { setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> - Fists
) - } - return entries - })()} + {combat && (() => { + const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) + const anyActionPossible = partyIds.some(cid => !isDead(combat.characters[cid]?.integrity)) + return anyActionPossible && ( +
+ {partyIds.map(cid => { + const ch = combat.characters[cid] + if (!ch) return null + const isDeadChar = isDead(ch.integrity) + const charEquipped = ch.equipped ?? { left: 'fists', right: 'fists' } + const lw = charEquipped.left, rw = charEquipped.right + const charName = ch.name ?? cid + return ( +
+
setExpandedWeapon(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> + {expandedWeapon.includes(cid) ? '▼' : '▶'} + { e.stopPropagation(); setShowInventory(true); setPanelTarget(cid) }}>{charName}{isDeadChar ? ' (Dead)' : ''}
- )} - {/* Items */} -
setItemsOpen(!itemsOpen)}> - {itemsOpen ? '▼' : '▶'} - Items -
- {itemsOpen && ( -
- {player.inventory.length === 0 - ?
No items
- : player.inventory.map((itemId, i) => { - const qty = player.itemCounts?.[itemId] - if (qty !== undefined && qty <= 0) return null - return ( -
{ setShowInventory(true); setPanelTarget('player') }}> - - {itemMap[itemId]?.name ?? itemId} - {qty !== undefined && ×{qty}} -
- ) - }) - } -
- )} + {expandedWeapon.includes(cid) && ( +
+ {/* Weapons */} +
setWeaponsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> + {weaponsOpen.includes(cid) ? '▼' : '▶'} + Weapons +
+ {weaponsOpen.includes(cid) && ( +
+ {(() => { + const entries = [] + if (lw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Left Arm
) + } else if (lw !== rw) { + entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Left: {weaponMap[lw]?.name ?? lw}
) + } + if (rw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Right Arm
) + } else if (lw !== rw) { + entries.push(
setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Right: {weaponMap[rw]?.name ?? rw}
) + } + if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') { + entries.push(
setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Both: {weaponMap[lw]?.name ?? lw}
) + } + if (lw === 'fists' && rw === 'fists') { + entries.push(
setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)} + onClick={() => { setCurrentActor(cid); setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}> + Fists
) + } + return entries + })()} +
+ )} + {/* Items */} +
setItemsOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> + {itemsOpen.includes(cid) ? '▼' : '▶'} + Items +
+ {itemsOpen.includes(cid) && ( +
+ {(ch.inventory ?? []).length === 0 + ?
No items
+ : ch.inventory.map((itemId, i) => { + const qty = ch.itemCounts?.[itemId] + if (qty !== undefined && qty <= 0) return null + return ( +
{ setShowInventory(true); setPanelTarget(cid) }}> + + {itemMap[itemId]?.name ?? itemId} + {qty !== undefined && ×{qty}} +
+ ) + }) + } +
+ )} - {/* Movement */} -
setMovementOpen(!movementOpen)}> - {movementOpen ? '▼' : '▶'} - Movement + {/* Movement */} +
setMovementOpen(prev => prev.includes(cid) ? prev.filter(x => x !== cid) : [...prev, cid])}> + {movementOpen.includes(cid) ? '▼' : '▶'} + Movement +
+ {movementOpen.includes(cid) && ( +
+
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'walk' ? null : 'walk'); setAimMode(false) } }}> + Walk +
+
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'run' ? null : 'run'); setAimMode(false) } }}> + Run +
+
{ if (!busy) { setCurrentActor(cid); setMoveMode(m => m === 'turn' ? null : 'turn'); setAimMode(false) } }}> + Turn +
+ {moveMode && moveMode !== 'turn' &&
Click on the mini-map to move
} + {moveMode === 'turn' &&
Click on the mini-map to face a direction
} +
+ )} +
+ )}
- {movementOpen && ( -
-
{ if (!busy) { setMoveMode(m => m === 'walk' ? null : 'walk'); setAimMode(false) } }}> - Walk -
-
{ if (!busy) { setMoveMode(m => m === 'run' ? null : 'run'); setAimMode(false) } }}> - Run -
-
{ if (!busy) { setMoveMode(m => m === 'turn' ? null : 'turn'); setAimMode(false) } }}> - Turn -
- {moveMode && moveMode !== 'turn' &&
Click on the mini-map to move
} - {moveMode === 'turn' &&
Click on the mini-map to face a direction
} -
- )} -
- )} + ) + })}
-
- )} + ) + })()}
{/* ── center: minimap + log */} @@ -1905,13 +2418,14 @@ class MoveAction { return } if (moveMode === 'turn') { - const pp = combat.characters['player'].pos + const turnActor = currentActor ?? 'player' + const pp = combat.characters[turnActor]?.pos ?? combat.characters['player'].pos const dx = tx - pp.x const dy = ty - pp.y const d = Math.sqrt(dx * dx + dy * dy) if (d > 0) { - characterActionsRef.current['player'] = new TurnAction({ targetDir: { dx: dx / d, dy: dy / d }, getDir: () => characterDirsRef.current['player'] ?? { dx: 0, dy: 1 }, setDir: d => { characterDirsRef.current['player'] = d } }) - setMoveMode(null) + 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 } }) + setMoveMode(null); setCurrentActor(null) } return } @@ -1932,7 +2446,7 @@ class MoveAction { transition: hitShake ? 'none' : undefined, }}> - {MAP_BLOCKS.map((block, i) => )} + {(combat?.blocks ?? []).map((block, i) => )} {(combat?.enemyIds ?? []).map(eid => { const enemy = combat.characters[eid] const dead = isDead(enemy.integrity) @@ -1943,25 +2457,52 @@ class MoveAction { return ( { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }); setHoveredChar(`enemy-${eid}`) }} onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }} - onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(eid) }} /> + onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(eid) }} + onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }} /> ) })} - {combat && selectedSkill && limbSelect && ( - - )} - {combat && ( - setHoveredChar('player')} onMouseLeave={() => setHoveredChar(null)} - onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget('player') }} /> - )} - {/* Weapon circles */} + {combat && selectedSkill && limbSelect && (() => { + const rangeActor = currentActor ?? 'player' + const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos + return + })()} + {/* 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(); setShowInventory(true); setPanelTarget(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(); setShowInventory(true); setPanelTarget(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 */} {combat && (() => { const wpnOff = (dir, side, off) => { if (side === 'front') return { x: dir.dx * off, y: dir.dy * off } @@ -1971,39 +2512,44 @@ class MoveAction { const wpnDots = (slots, bx, by) => slots.map(s => ) - const dir = characterDirsRef.current['player'] ?? { dx: 0, dy: 1 } - const off = 7 - const shake = hitShake?.actor === 'player' - const bx = shake ? combat.characters['player'].pos.x + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.x - const by = shake ? combat.characters['player'].pos.y + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.y - const lw = player.equipped.left, rw = player.equipped.right const isFist = w => w === 'fists' || w === 'left_arm' || w === 'right_arm' - const bothWpn = lw === rw && !isFist(lw) const isAtk = t => [ANIM_TYPES.SLASH, ANIM_TYPES.PIERCE_STRIKE, ANIM_TYPES.FIST_STRIKE, ANIM_TYPES.FIST_HOOK].includes(t) - const slots = [] - - const playerPos = combat.characters['player'].pos - if (bothWpn) { - const anim = animations.find(a => a.actor === 'player' && isAtk(a.type) && a.weaponId === lw) - const p = anim ? { x: getAnimPos(anim).x - playerPos.x, y: getAnimPos(anim).y - playerPos.y } : wpnOff(dir, 'front', off) - slots.push({ key: 'pw-both', ox: p.x, oy: p.y, fill: '#fbbf24' }) - } else { - const lAnim = animations.find(a => a.actor === 'player' && isAtk(a.type) && (a.weaponId === lw || a.weaponId === 'left_arm')) - const rAnim = animations.find(a => a.actor === 'player' && isAtk(a.type) && (a.weaponId === rw || a.weaponId === 'right_arm')) - const bothAnim = animations.find(a => a.actor === 'player' && 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 - playerPos.x, y: getAnimPos(ap).y - playerPos.y } : wpnOff(dir, 'left', off) - slots.push({ key: 'pw-left', ox: p.x, oy: p.y, fill: isFist(lw) ? player.fillColor : '#fbbf24' }) + const allSlots = [] + const partyIds = combat.partyIds ?? [combat.playerEntityId].filter(Boolean) + partyIds.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 (!isFist(rw) || rAnim || bothAnim) { - const ap = (isFist(rw) && bothAnim) ? bothAnim : rAnim - const p = ap ? { x: getAnimPos(ap).x - playerPos.x, y: getAnimPos(ap).y - playerPos.y } : wpnOff(dir, 'right', off) - slots.push({ key: 'pw-right', ox: p.x, oy: p.y, fill: isFist(rw) ? player.fillColor : '#fbbf24' }) - } - } - return wpnDots(slots, bx, by) + }) + const flat = allSlots.map(s => ) + return flat })()} {combat && combat.enemyIds.map(eid => { const enemy = combat.characters[eid] @@ -2026,22 +2572,28 @@ class MoveAction { ))} {/* Direction arrows (on top, in front of character) */} - {combat && !isDead(player.integrity) && (() => { - const d = characterDirsRef.current['player'] ?? { dx: 0, dy: 1 } - const cx = hitShake && hitShake.actor === 'player' - ? combat.characters['player'].pos.x + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.x - const cy = hitShake && hitShake.actor === 'player' - ? combat.characters['player'].pos.y + (Math.random() - 0.5) * 1.5 : combat.characters['player'].pos.y - const isHovered = hoveredChar === 'player' - const px = -d.dy, py = d.dx - return ( - - ) + {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 ( + + ) + }) })()} {combat && combat.enemyIds.map(eid => { const enemy = combat.characters[eid] @@ -2080,7 +2632,7 @@ class MoveAction { const enemy = combat.characters[eid] const idx = combat.enemyIds.indexOf(eid) return ( -
{ setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }}> +
{ setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}> {enemy.name}
) @@ -2090,7 +2642,17 @@ class MoveAction {
{/* ── attack modal */} - {limbSelect && ( + {limbSelect && (() => { + const actorId = currentActor ?? 'player' + const actorChar = combat?.characters[actorId] + // If the current actor is dead, find the next alive party member + const liveActorId = (actorChar && isDead(actorChar.integrity)) + ? (combat.partyIds ?? []).find(pid => !isDead(combat.characters[pid]?.integrity)) ?? 'player' + : actorId + const resolvedActorChar = combat?.characters[liveActorId] + const actorPos = resolvedActorChar ? (characterPositionsRef.current[liveActorId] ?? resolvedActorChar.pos) : { x: 20, y: 75 } + const actorEquipped = resolvedActorChar?.equipped ?? { left: 'fists', right: 'fists' } + return (
e.stopPropagation()}> @@ -2101,8 +2663,8 @@ class MoveAction { return (
- {weaponName} - {limbSelect === 'pistol' && Ammo: {player.weaponMagazines.pistol?.length ?? 0}/{PISTOL_MAG} ({itemMap[player.weaponMagazines.pistol?.[0]?.ammoType]?.name ?? 'none'})} + {weaponName} — {actorChar?.name ?? actorId} + {limbSelect === 'pistol' && Ammo: {actorChar?.weaponMagazines?.pistol?.length ?? 0}/{PISTOL_MAG} ({itemMap[actorChar?.weaponMagazines?.pistol?.[0]?.ammoType]?.name ?? 'none'})}
{/* skill list */} @@ -2113,7 +2675,8 @@ class MoveAction { {skills.map((skill, i) => (
)} {/* reload: ammo selection */} - {selectedSkill === 'Reload' && ( + {selectedSkill === 'Load' && (
Select Ammo Type
- {Object.entries(playerRef.current.itemCounts ?? {}) + {Object.entries(actorChar?.itemCounts ?? {}) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) .length === 0 ?
No ammo available
- : Object.entries(playerRef.current.itemCounts ?? {}) + : Object.entries(actorChar?.itemCounts ?? {}) .filter(([id, qty]) => qty > 0 && (id === 'pistol_ammo' || id === 'bouncing_ammo')) .map(([id, qty]) => (
)} {/* enemy list (hidden during reload) */} - {selectedSkill && selectedSkill !== 'Reload' && ( + {selectedSkill && selectedSkill !== 'Load' && (
Select Enemy
{(combat?.enemyIds ?? []).map((eid, i) => { const enemy = combat.characters[eid] - const inRange = canReach(combat.characters['player'].pos, enemy.pos, limbSelect, selectedSkill) + const inRange = canReach(actorPos, enemy.pos, limbSelect, selectedSkill) return (
)} {/* limb selector (hidden during reload) */} - {selectedSkill !== 'Reload' && attackEnemy !== null && ( + {selectedSkill !== 'Load' && attackEnemy !== null && (
Limb
@@ -2239,28 +2802,18 @@ class MoveAction {
{limbSelect !== 'pistol' && } + disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || (resolvedActorChar ? isDead(resolvedActorChar.integrity) : false)} + style={{ opacity: (!selectedSkill || attackEnemy === null || !selectedTarget || busy || (resolvedActorChar ? isDead(resolvedActorChar.integrity) : false)) ? 0.4 : 1 }}>Confirm}
- )} - - {/* ── combat over */} - {combat && (isDead(player.integrity) || !(combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity))) && ( -
-
Combat Over
-
- {fightRecord && } - -
-
- )} -
+ ) + })()} + )} {/* ═══════════════════════════════════════════════════════ WORLD MAP */} - {!combat && ( + {(!combat || showWorldMap) && ( <> setHovered(l.id)} onMouseLeave={() => setHovered(null)} /> {l.id} - {shops.some(s => s.locationId === l.id) && ( - S - )} {!travelAnim && player.location === l.id && ( <> P @@ -2302,32 +2852,6 @@ class MoveAction { -
-
- - - - -
-
-
{dateString}, {String(displayHour).padStart(2,'0')}:00
-
{weatherLabels[weather]}
-
-
- -
- - -
- {passTimeOpen && (
Pass Time
@@ -2339,7 +2863,7 @@ class MoveAction { @@ -2366,24 +2890,12 @@ class MoveAction {
)} - {shopAtLoc && ( -
-
{shopAtLoc.name}
- {shopAtLoc.items.map((item, i) => ( -
- {item.name} - {item.price}g - -
- ))} -
- )}
Characters here
{liveCharacters.filter(c => c.location === selected).length === 0 ?
None
: liveCharacters.filter(c => c.location === selected).map(c => ( -
+
{ e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: c.id }) }}>
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
{c.money !== undefined && {c.money}g} @@ -2393,89 +2905,11 @@ class MoveAction { )) }
- {player.location === selected && ( - - )}
)} - {showLocationModal && ( -
{ setShowLocationModal(null); setActionNpc(null) }}> -
e.stopPropagation()}> -
-
{locMap[showLocationModal]?.name}
- -
-
-
-
Log
-
- {locationLog.map((line, i) =>
{line}
)} -
-
-
Description
-
-
{locMap[showLocationModal]?.description}
-
Connections
- {connections.filter(c => c.from === showLocationModal || c.to === showLocationModal).map(c => { - const id = c.from === showLocationModal ? c.to : c.from - return
{locMap[id]?.name}
- })} - {shops.filter(s => s.locationId === showLocationModal).length > 0 && ( - <> -
Shops
- {shops.filter(s => s.locationId === showLocationModal).map(s =>
{s.name}
)} - - )} -
-
-
-
-
-
Surroundings
-
- {(() => { - const corpsesHere = defeatedNpcs - .map(id => characters.find(c => c.id === id)) - .filter(c => c && c.location === showLocationModal) - return corpsesHere.length === 0 - ?
Nothing of interest.
- : corpsesHere.map(c =>
{c.name}'s corpse
) - })()} -
-
-
-
Characters
-
- {liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).length === 0 - ?
No one here.
- : liveCharacters.filter(c => c.id !== 'player' && c.location === showLocationModal).map(c => ( -
-
{ setShowInventory(true); setPanelTarget(c.id) }}>{c.name}
- -
- )) - } -
-
-
-
- {actionNpc && ( -
setActionNpc(null)}> -
e.stopPropagation()}> - -
- -
-
-
- )} -
-
- )} + )} @@ -2492,7 +2926,7 @@ class MoveAction { return (
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null); setContextMenu(null) }} - onContextMenu={e => { e.preventDefault(); setContextMenu(null) }} +onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }} >
e.stopPropagation()}> @@ -2584,7 +3018,9 @@ class MoveAction { return (
setHoverInfo({ type:'item', id:itemId })} onMouseLeave={() => setHoverInfo(null)}> {itemMap[itemId]?.name ?? itemId} - {qty !== undefined && ×{qty}} + {qty !== undefined + ? ×{qty} + : itemMap[itemId] && ×1} {isPlayer && weaponMap[itemId] && }
) @@ -2627,7 +3063,7 @@ class MoveAction {
Game
Date{getDateString(gameTime.day)}
-
Time{String(gameTime.hour).padStart(2,'0')}:00
+
Time{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}
Weather{weatherLabels[weather]}
@@ -2647,7 +3083,7 @@ class MoveAction {
World
Date{getDateString(gameTime.day)}
-
Time{String(gameTime.hour).padStart(2,'0')}:00
+
Time{String(gameTime.hour).padStart(2,'0')}:{String(Math.floor(gameTime.minute ?? 0)).padStart(2,'0')}
Weather{weatherLabels[weather]}
Location{locMap[player.location]?.name ?? player.location}
@@ -2679,25 +3115,158 @@ class MoveAction { }
)} + + {/* ═══════════════════════════════════════════════════════ TRADE WINDOW */} + {showTrade && (() => { + const srcChar = player + const tgtChar = (() => { + if (tradeTarget === 'player') return player + if (combat && combat.characters[tradeTarget]) return combat.characters[tradeTarget] + const npc = characters.find(c => c.id === tradeTarget) + return npc ? createCharacter(npc) : null + })() + if (!srcChar || !tgtChar) return null + function getCharItems(ch) { + const seen = new Set() + const items = [] + for (const id of (ch.inventory ?? [])) { + if (seen.has(id)) continue; seen.add(id) + items.push({ id, name: weaponMap[id]?.name ?? itemMap[id]?.name ?? id, qty: 1, value: getItemValue(id), cond: ch.weaponConditions?.[id] ?? weaponMap[id]?.maxCondition ?? null, maxCond: weaponMap[id]?.maxCondition ?? null }) + } + for (const [id, qty] of Object.entries(ch.itemCounts ?? {})) { + if (qty > 0) { + if (seen.has(id)) continue; seen.add(id) + items.push({ id, name: itemMap[id]?.name ?? id, qty, value: getItemValue(id) }) + } + } + return items + } + const srcItems = getCharItems(srcChar) + const tgtItems = getCharItems(tgtChar) + const giveVal = Object.entries(tradeGive).reduce((s, [id, q]) => s + getItemValue(id) * q, 0) + const takeVal = Object.entries(tradeTake).reduce((s, [id, q]) => s + getItemValue(id) * q, 0) + const proposeTrade = () => { + const giveEntries = Object.entries(tradeGive).filter(([id, q]) => q > 0) + const takeEntries = Object.entries(tradeTake).filter(([id, q]) => q > 0) + if (giveEntries.length === 0 && takeEntries.length === 0) return + const newPlayer = { ...player } + const tgtWorld = characters.find(c => c.id === tradeTarget) + if (tgtWorld) { + tgtWorld.inventory = [...(tgtWorld.inventory ?? [])] + tgtWorld.itemCounts = { ...(tgtWorld.itemCounts ?? {}) } + tgtWorld.weaponConditions = { ...(tgtWorld.weaponConditions ?? {}) } + } + for (const [id, qty] of giveEntries) { + if (weaponMap[id]) { + // weapon: remove from player inventory, give to target + newPlayer.inventory = newPlayer.inventory.filter(i => i !== id) + if (tgtWorld) { + tgtWorld.inventory = tgtWorld.inventory ?? [] + tgtWorld.inventory.push(id) + if (newPlayer.weaponConditions?.[id] != null) { + tgtWorld.weaponConditions[id] = newPlayer.weaponConditions[id] + delete newPlayer.weaponConditions[id] + } + } + } else if (itemMap[id]) { + // ammo/item: reduce player count, increase target count + newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } + newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) - qty + if (newPlayer.itemCounts[id] <= 0) delete newPlayer.itemCounts[id] + if (tgtWorld) { + const tgt = tgtWorld + tgt.inventory = [...(tgt.inventory ?? []), id] + tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } + tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) + qty + } + } + } + for (const [id, qty] of takeEntries) { + if (weaponMap[id]) { + // weapon: remove from target inventory, give to player + if (tgtWorld) { + tgtWorld.inventory = tgtWorld.inventory.filter(i => i !== id) + if (tgtWorld.weaponConditions?.[id] != null) { + newPlayer.weaponConditions = { ...(newPlayer.weaponConditions ?? {}) } + newPlayer.weaponConditions[id] = tgtWorld.weaponConditions[id] + delete tgtWorld.weaponConditions[id] + } + } + newPlayer.inventory = [...(newPlayer.inventory ?? []), id] + } else if (itemMap[id]) { + // ammo/item: reduce target count (from itemCounts and/or inventory) + if (tgtWorld) { + const tgt = tgtWorld + tgt.inventory = (tgt.inventory ?? []).filter(i => i !== id) + tgt.itemCounts = { ...(tgt.itemCounts ?? {}) } + tgt.itemCounts[id] = (tgt.itemCounts[id] ?? 0) - qty + if (tgt.itemCounts[id] <= 0) delete tgt.itemCounts[id] + } + newPlayer.itemCounts = { ...(newPlayer.itemCounts ?? {}) } + newPlayer.itemCounts[id] = (newPlayer.itemCounts[id] ?? 0) + qty + } + } + setPlayer(newPlayer) + setShowTrade(false) + setTradeTarget(null) + setTradeGive({}) + setTradeTake({}) + } + return ( +
{ setShowTrade(false); setTradeTarget(null) }}> +
e.stopPropagation()}> + +
Trade with {tgtChar.name ?? tradeTarget}
+
+
+
{srcChar.name} Offers
+
+ {srcItems.map(item => ( +
+ {item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''} +
+ + {tradeGive[item.id] ?? 0} + +
+
+ ))} +
+
+
+
{tgtChar.name} Offers
+
+ {tgtItems.map(item => ( +
+ {item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''} +
+ + {tradeTake[item.id] ?? 0} + +
+
+ ))} +
+
+
+
+ Give: {giveVal}g   Take: {takeVal}g +
+ +
+
+ ) + })()} + {hoverInfo?.type === 'enemy' && (
{hoverInfo.name}
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}
)} - - {/* ═══════════════════════════════════════════════════════ BUY ARM CHOICE */} - {buyArmChoice && ( -
setBuyArmChoice(null)}> -
e.stopPropagation()}> -
Equip {buyArmChoice.item.name}
-
- - - -
- -
+ {hoveredChar && !hoveredChar.startsWith('enemy-') && combat?.characters[hoveredChar] && ( +
+
{combat.characters[hoveredChar].name ?? hoveredChar}
)} @@ -2715,6 +3284,13 @@ class MoveAction {
)} + + {/* ═══════════════════════════════════════════════════════ CONTEXT MENU */} + {contextMenu && ( +
{ setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}> + +
+ )}
) } \ No newline at end of file diff --git a/src/assets/pistolshot.mp3 b/src/assets/pistolshot.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d10e1b19da3ad8647202a018e92de86928ae15e8 GIT binary patch literal 30374 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 literal 0 HcmV?d00001