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