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
+
+
+ Name
+ setName(e.target.value)} className="char-create-input" maxLength={20} />
+
+
+
+
Starting Weapon
+
+ {startWeapons.map(w => (
+ setWeaponId(w.id)}>{w.name}
+ ))}
+
+
+
+
+ Fill Color
+ setFillColor(e.target.value)} className="char-create-color" />
+
+
+
+
Starting Location
+
+ {locations.map(l => (
+ setLocationId(l.id)}>{l.id} — {l.name}
+ ))}
+
+
+
+
onConfirm({ name, weaponId, fillColor, locationId })}>Start Game
+
+
+ )
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// 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
+
New Game
+
+
+ )
+ }
+
+ 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 && (
+
setShowWorldMap(s => !s)} title={showWorldMap ? 'Combat View' : 'World Map'}>
+
+
+ )}
+
{ setShowInventory(true); setPanelTarget('player') }} title="Character">
+
+
+
setShowWorldInfo(s => !s)} title="World">
+
+
+
{ setPassTimeHours(1); setPassTimeOpen(s => !s) }} title="Pass Time">
+
+
+
setShowSettings(s => !s)} title="Settings">
+
+
+
+
+ {(!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 && (
{timerRunning ? (
@@ -1736,7 +2224,14 @@ class MoveAction {
)}
- 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 => (
{ speedRef.current = s; setSpeedMult(s) }}
@@ -1746,118 +2241,136 @@ class MoveAction {
))}
-
+ )}
+
+
-
- {/* ── 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) => (
{
- if (skill.name === 'Reload') { startReload(); return }
+ if (skill.name === 'Load') { startReload(); return }
+ if (skill.name === 'Unload') { unloadPistol(); return }
setSelectedSkill(skill.name); setAttackEnemy(null)
if (limbSelect === 'pistol') { setAimMode(true); setLimbSelect(null) }
}}
@@ -2125,15 +2688,15 @@ class MoveAction {
)}
{/* 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 (
setAttackEnemy(i)} style={{ opacity: inRange ? 1 : 0.4 }} disabled={!inRange}
@@ -2169,7 +2732,7 @@ class MoveAction {
)}
{/* limb selector (hidden during reload) */}
- {selectedSkill !== 'Reload' && attackEnemy !== null && (
+ {selectedSkill !== 'Load' && attackEnemy !== null && (
Limb
@@ -2239,28 +2802,18 @@ class MoveAction {
{limbSelect !== 'pistol' && Confirm }
+ 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}
Cancel
- )}
-
- {/* ── combat over */}
- {combat && (isDead(player.integrity) || !(combat.enemyIds ?? []).some(eid => !isDead(combat.characters[eid].integrity))) && (
-
-
Combat Over
-
- {fightRecord && Replay }
- { setCombat(null); setReplayActive(false); setProjectiles([]) }}>Exit
-
-
- )}
-
+ )
+ })()}
+ >
)}
{/* ═══════════════════════════════════════════════════════ 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 {
-
-
-
{ setShowInventory(true); setPanelTarget('player') }} title="Character">
-
-
-
setShowWorldInfo(s => !s)} title="World">
-
-
-
{ setPassTimeHours(1); setPassTimeOpen(s => !s) }} title="Pass Time">
-
-
-
setShowSettings(s => !s)} title="Settings">
-
-
-
-
-
{dateString}, {String(displayHour).padStart(2,'0')}:00
-
{weatherLabels[weather]}
-
-
-
-
- +
- −
-
-
{passTimeOpen && (
Pass Time
@@ -2339,7 +2863,7 @@ class MoveAction {
{
const totalH = gameTime.day * 24 + gameTime.hour
const target = totalH + passTimeHours
- setGameTime(gt => { let h = gt.hour + passTimeHours, d = gt.day; if (h >= 24) { d += Math.floor(h/24); h = h%24 } return { day: d, hour: h } })
+ setGameTime(gt => { let h = gt.hour + passTimeHours, d = gt.day; if (h >= 24) { d += Math.floor(h/24); h = h%24 } return { day: d, hour: h, minute: 0 } })
setSkipAnim({ current: totalH, target, start: performance.now() })
setPassTimeOpen(false)
}}>Skip
@@ -2366,24 +2890,12 @@ class MoveAction {
travel(selected)}>Go to {locMap[selected].name}
)}
- {shopAtLoc && (
-
-
{shopAtLoc.name}
- {shopAtLoc.items.map((item, i) => (
-
- {item.name}
- {item.price}g
- buyItem(item)}>Buy
-
- ))}
-
- )}
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 && (
-
{ setShowLocationModal(selected); setLocationLog([`Entered ${locMap[selected].name}.`]) }}>
- Enter {locMap[selected].name}
-
- )}
setSelected(null)}>Close
)}
- {showLocationModal && (
-
{ setShowLocationModal(null); setActionNpc(null) }}>
-
e.stopPropagation()}>
-
-
{locMap[showLocationModal]?.name}
-
{ setShowLocationModal(null); setActionNpc(null) }}>X
-
-
-
-
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}
-
setActionNpc(c.id)}>Action
-
- ))
- }
-
-
-
-
- {actionNpc && (
-
setActionNpc(null)}>
-
e.stopPropagation()}>
-
setActionNpc(null)}>X
-
- { setActionNpc(null); setShowLocationModal(null); startFight() }}>Fight
-
-
-
- )}
-
-
- )}
+
>
)}
@@ -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()}>
{ setShowInventory(false); setPanelTarget('player'); setHoverInfo(null) }}>X
@@ -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] && setEquipArmChoice(itemId)}>Equip }
)
@@ -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()}>
+
{ setShowTrade(false); setTradeTarget(null) }}>X
+
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}]` : ''}
+
+ setTradeGive(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.id] ?? 0) - 1) }))}>-
+ {tradeGive[item.id] ?? 0}
+ setTradeGive(prev => ({ ...prev, [item.id]: Math.min(item.qty, (prev[item.id] ?? 0) + 1) }))}>+
+
+
+ ))}
+
+
+
+
{tgtChar.name} Offers
+
+ {tgtItems.map(item => (
+
+
{item.name}{item.qty > 1 ? ` (${item.qty})` : ''}{item.cond != null ? ` [${item.cond}/${item.maxCond}]` : ''}
+
+ setTradeTake(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.id] ?? 0) - 1) }))}>-
+ {tradeTake[item.id] ?? 0}
+ setTradeTake(prev => ({ ...prev, [item.id]: Math.min(item.qty, (prev[item.id] ?? 0) + 1) }))}>+
+
+
+ ))}
+
+
+
+
+ Give: {giveVal}g Take: {takeVal}g
+
+
Propose Trade
+
+
+ )
+ })()}
+
{hoverInfo?.type === 'enemy' && (
{hoverInfo.name}
{hoverInfo.defeated ? 'Defeated' : 'Hostile'}
)}
-
- {/* ═══════════════════════════════════════════════════════ BUY ARM CHOICE */}
- {buyArmChoice && (
-
setBuyArmChoice(null)}>
-
e.stopPropagation()}>
-
Equip {buyArmChoice.item.name}
-
- confirmEquipArm('left')}>Left Arm
- confirmEquipArm('right')}>Right Arm
- confirmEquipArm('both')}>Both Arms
-
-
setBuyArmChoice(null)}>Cancel
-
+ {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) }}>
+ { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade
+
+ )}
)
}
\ No newline at end of file
diff --git a/src/assets/pistolshot.mp3 b/src/assets/pistolshot.mp3
new file mode 100644
index 0000000..d10e1b1
Binary files /dev/null and b/src/assets/pistolshot.mp3 differ