broken target select

This commit is contained in:
2026-06-19 01:12:35 -04:00
parent 4270cdca75
commit 2ff7932048
2 changed files with 203 additions and 220 deletions
+18
View File
@@ -479,6 +479,24 @@ body {
margin-left: 16px; margin-left: 16px;
} }
.tree-leaf-selected {
background: #2d2d44;
color: #fcd34d;
}
.tree-leaf-selected:hover {
background: #3d3d54;
}
.tree-folder-selected {
background: #2d2d44;
color: #fcd34d !important;
}
.tree-folder-selected:hover {
background: #3d3d54;
}
.combat-back { .combat-back {
background: none; background: none;
border: none; border: none;
+185 -220
View File
@@ -561,6 +561,7 @@ export default function App() {
const [weaponsOpen, setWeaponsOpen] = useState([]) const [weaponsOpen, setWeaponsOpen] = useState([])
const [itemsOpen, setItemsOpen] = useState([]) const [itemsOpen, setItemsOpen] = useState([])
const [movementOpen, setMovementOpen] = useState([]) const [movementOpen, setMovementOpen] = useState([])
const [skillDropdownOpen,setSkillDropdownOpen]= useState([])
const [limbSelect, setLimbSelect] = useState(null) const [limbSelect, setLimbSelect] = useState(null)
const [selectedTarget, setSelectedTarget] = useState(null) const [selectedTarget, setSelectedTarget] = useState(null)
const [selectedSkill, setSelectedSkill] = useState(null) const [selectedSkill, setSelectedSkill] = useState(null)
@@ -598,13 +599,7 @@ export default function App() {
// ── notifications // ── notifications
const [notifications, setNotifications] = useState([]) const [notifications, setNotifications] = useState([])
useEffect(() => { // auto-dismiss a notification every 4s
const id = setInterval(() => {
setNotifications(prev => [...prev, { id: Date.now(), subject: 'Test', description: 'This is a test notification.' }])
}, 5000)
return () => clearInterval(id)
}, [])
useEffect(() => { useEffect(() => {
if (notifications.length === 0) return if (notifications.length === 0) return
const id = setTimeout(() => setNotifications(prev => prev.slice(1)), 4000) const id = setTimeout(() => setNotifications(prev => prev.slice(1)), 4000)
@@ -1013,7 +1008,7 @@ export default function App() {
const actorId = currentActor ?? 'player' const actorId = currentActor ?? 'player'
const startPos = characterPositionsRef.current[actorId] ?? combat.characters[actorId]?.pos ?? combat.characters[combat.playerEntityId].pos 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 }) 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) setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([])
} }
/** /**
@@ -1817,7 +1812,7 @@ class MoveAction {
} }
recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null) recordingRef.current = null; setFightRecord(null); setProjectiles([]); setHitShake(null)
speedRef.current = 1; setSpeedMult(1) speedRef.current = 1; setSpeedMult(1)
setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) setAimMode(false); setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
} }
const endFight = () => { const endFight = () => {
@@ -1911,17 +1906,17 @@ class MoveAction {
characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } }, characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } },
log: [...c.log, `${actorChar.name} shoves ${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); setCurrentActor(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
return return
} }
const anim = createWeaponStrikeAnim(action, combatRef.current) const anim = createWeaponStrikeAnim(action, combatRef.current)
characterActionsRef.current[actorId] = anim characterActionsRef.current[actorId] = anim
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
} }
const cancelAttack = () => { const cancelAttack = () => {
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setCurrentActor(null); setSkillDropdownOpen([])
setAimMode(false) setAimMode(false)
} }
@@ -2012,7 +2007,7 @@ class MoveAction {
if (reserve <= 0) return if (reserve <= 0) return
const ammoName = itemMap[ammoId]?.name ?? ammoId const ammoName = itemMap[ammoId]?.name ?? ammoId
new Audio(chamberBulletMp3).play().catch(() => {}) new Audio(chamberBulletMp3).play().catch(() => {})
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setSkillDropdownOpen([])
setTimeout(() => { setTimeout(() => {
if (actorId === 'player') { if (actorId === 'player') {
const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] } const newMag = { pistol: [...magazineRef.current.pistol, { ammoType: ammoId }] }
@@ -2077,7 +2072,7 @@ class MoveAction {
const ammoId = top.ammoType const ammoId = top.ammoType
const ammoName = itemMap[ammoId]?.name ?? ammoId const ammoName = itemMap[ammoId]?.name ?? ammoId
new Audio(chamberBulletMp3).play().catch(() => {}) new Audio(chamberBulletMp3).play().catch(() => {})
setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null) setLimbSelect(null); setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null); setSkillDropdownOpen([])
setTimeout(() => { setTimeout(() => {
if (actorId === 'player') { if (actorId === 'player') {
const newMag = { pistol: mag.pistol.slice(0, -1) } const newMag = { pistol: mag.pistol.slice(0, -1) }
@@ -2203,6 +2198,111 @@ class MoveAction {
const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' } const partMap = { larm: 'leftArm', rarm: 'rightArm', lleg: 'leftLeg', rleg: 'rightLeg' }
const selectLimbTarget = (part) => setSelectedTarget(partMap[part] || part) const selectLimbTarget = (part) => setSelectedTarget(partMap[part] || part)
/** Render inline skill → enemy → limb → confirm dropdown inside the action tree */
const renderSkillDropdown = (weaponId, actorId) => {
const weapon = weaponMap[weaponId]
if (!weapon) return null
const skills = weapon.skills ?? []
const isPistol = weaponId === 'pistol'
const ac = combat?.characters[actorId]
const actorPos = ac ? (characterPositionsRef.current[actorId] ?? ac.pos) : null
return (
<div className="tree-children">
{skills.map((skill, si) => {
const active = selectedSkill === skill.name
const isSpecial = skill.name === 'Load' || skill.name === 'Unload'
return (
<Fragment key={si}>
{active && isSpecial ? (
<div className="tree-node leaf tree-leaf-selected"
onClick={() => {
if (skill.name === 'Load') { startReload(); return }
if (skill.name === 'Unload') { unloadPistol(); return }
}}
onMouseEnter={() => setHoveredSkill(skill.name)}
onMouseLeave={() => setHoveredSkill(null)}>
<span className="tree-bullet"></span>
<span className="tree-label">{skill.name}</span>
</div>
) : active ? (
<div className="tree-node folder tree-folder-selected"
onClick={() => {
setAttackEnemy(null); setSelectedTarget(null)
setAimMode(true)
}}
onMouseEnter={() => setHoveredSkill(skill.name)}
onMouseLeave={() => setHoveredSkill(null)}>
<span className="tree-toggle"></span>
<span className="tree-label">{skill.name}</span>
</div>
) : (
<div className="tree-node leaf"
onClick={() => {
if (isSpecial) { if (skill.name === 'Load') { startReload(); return }; if (skill.name === 'Unload') { unloadPistol(); return } }
setSelectedSkill(skill.name); setLimbSelect(weaponId); setCurrentActor(actorId)
setAttackEnemy(null); setSelectedTarget(null)
setAimMode(true)
}}
onMouseEnter={() => setHoveredSkill(skill.name)}
onMouseLeave={() => setHoveredSkill(null)}>
<span className="tree-bullet"></span>
<span className="tree-label">{skill.name}</span>
</div>
)}
{/* Targeting / limb selection when this skill is active */}
{active && !isSpecial && (
<div className="tree-children">
{attackEnemy === null ? (
<div className="tree-node leaf" style={{ cursor: 'default', color: '#9ca3af', fontSize: 12, fontStyle: 'italic' }}>
Click the minimap to target
</div>
) : (
<>
{[{ key: 'torso', label: 'Torso' }, { key: 'head', label: 'Head' }, { key: 'larm', label: 'Left Arm' }, { key: 'rarm', label: 'Right Arm' }, { key: 'lleg', label: 'Left Leg' }, { key: 'rleg', label: 'Right Leg' }].map(({ key, label }) => (
<div key={key}
className={'tree-node leaf' + (selectedTarget === (partMap[key] || key) ? ' tree-leaf-selected' : '')}
onClick={() => selectLimbTarget(key)}>
<span className="tree-bullet"></span>
<span className="tree-label">{label}</span>
</div>
))}
<div className="tree-node leaf" style={{ cursor: 'default', padding: '6px 8px', gap: 8 }}>
<button className="limb-confirm-btn" onClick={executeAttack}
disabled={!selectedSkill || attackEnemy === null || !selectedTarget || busy || (ac ? isDead(ac.integrity) : false)}
style={{ padding: '4px 14px', fontSize: 12, opacity: (!selectedSkill || attackEnemy === null || !selectedTarget || busy || (ac ? isDead(ac.integrity) : false)) ? 0.4 : 1 }}>
Confirm
</button>
<button className="limb-cancel-btn" onClick={cancelAttack} style={{ padding: '4px 14px', fontSize: 12 }}>Cancel</button>
</div>
</>
)}
</div>
)}
</Fragment>
)
})}
{/* Ammo selection when reloading */}
{selectedSkill === 'Load' && (
<div className="tree-children">
{(() => {
const ammoItems = Object.entries(ac?.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize))
return ammoItems.length === 0
? <div className="tree-node leaf" style={{ cursor: 'default', color: '#6b7280', fontSize: 12 }}>No ammo available</div>
: ammoItems.map(([id, qty]) => (
<div key={id} className="tree-node leaf" onClick={() => loadAmmoType(id)}>
<span className="tree-bullet"></span>
<span className="tree-label">{itemMap[id]?.name ?? id}</span>
<span style={{ marginLeft: 6, color: '#9ca3af', fontSize: 12 }}>×{qty}</span>
</div>
))
})()}
</div>
)}
</div>
)
}
const skipTotalHours = skipAnim !== null ? skipAnim.current : null const skipTotalHours = skipAnim !== null ? skipAnim.current : null
const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day const displayDay = skipTotalHours !== null ? Math.floor(skipTotalHours / 24) : gameTime.day
const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour const displayHour = skipTotalHours !== null ? Math.floor(skipTotalHours % 24) : gameTime.hour
@@ -2369,42 +2469,46 @@ class MoveAction {
{weaponsOpen.includes(cid) && ( {weaponsOpen.includes(cid) && (
<div className="tree-children"> <div className="tree-children">
{(() => { {(() => {
const entries = [] const makeWeap = (weaponId, label) => {
const dropKey = `${cid}-${weaponId}`
const isOpen = skillDropdownOpen.includes(dropKey)
return (
<Fragment key={weaponId}>
<div className="tree-node folder"
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: weaponId })}
onMouseLeave={() => setHoverInfo(null)}
onClick={() => {
const collapse = skillDropdownOpen.includes(dropKey)
setSkillDropdownOpen(prev => collapse ? prev.filter(x => x !== dropKey) : [...prev, dropKey])
setCurrentActor(cid); setLimbSelect(collapse ? null : weaponId)
setSelectedSkill(null); setSelectedTarget(null); setAttackEnemy(null)
setMoveMode(null); setAimMode(false)
}}>
<span className="tree-toggle">{isOpen ? '▼' : '▶'}</span>
<span className="tree-label">{label}</span>
</div>
{isOpen && renderSkillDropdown(weaponId, cid)}
</Fragment>
)
}
const pieces = []
if (lw === 'fists') { if (lw === 'fists') {
entries.push(<div key="left-arm" className="tree-node leaf" pieces.push(makeWeap('left_arm', 'Left Arm'))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'left_arm' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('left_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Left Arm</span></div>)
} else if (lw !== rw) { } else if (lw !== rw) {
entries.push(<div key="left-weap" className="tree-node leaf" pieces.push(makeWeap(lw, `Left: ${weaponMap[lw]?.name ?? lw}`))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Left: {weaponMap[lw]?.name ?? lw}</span></div>)
} }
if (rw === 'fists') { if (rw === 'fists') {
entries.push(<div key="right-arm" className="tree-node leaf" pieces.push(makeWeap('right_arm', 'Right Arm'))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'right_arm' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('right_arm'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Right Arm</span></div>)
} else if (lw !== rw) { } else if (lw !== rw) {
entries.push(<div key="right-weap" className="tree-node leaf" pieces.push(makeWeap(rw, `Right: ${weaponMap[rw]?.name ?? rw}`))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: rw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(rw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Right: {weaponMap[rw]?.name ?? rw}</span></div>)
} }
if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') { if (lw === rw && lw !== 'fists' && lw !== 'left_arm' && lw !== 'right_arm') {
entries.push(<div key="both-weap" className="tree-node leaf" pieces.push(makeWeap(lw, `Both: ${weaponMap[lw]?.name ?? lw}`))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: lw })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect(lw); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Both: {weaponMap[lw]?.name ?? lw}</span></div>)
} }
if (lw === 'fists' && rw === 'fists') { if (lw === 'fists' && rw === 'fists') {
entries.push(<div key="fists" className="tree-node leaf" pieces.push(makeWeap('fists', 'Fists'))
onMouseEnter={() => setHoverInfo({ type: 'weapon', id: 'fists' })} onMouseLeave={() => setHoverInfo(null)}
onClick={() => { setCurrentActor(cid); setLimbSelect('fists'); setSelectedSkill(null); setSelectedTarget(null); setMoveMode(null); setAimMode(false) }}>
<span className="tree-bullet"></span><span className="tree-label">Fists</span></div>)
} }
return entries return pieces
})()} })()}
</div> </div>
)} )}
@@ -2464,17 +2568,19 @@ class MoveAction {
{/* ── center: minimap + log */} {/* ── center: minimap + log */}
<div className="combat-center"> <div className="combat-center">
<div className="minimap-container" style={{ position: 'relative', borderColor: hitShake ? '#ef4444' : undefined, cursor: aimMode ? 'crosshair' : undefined }}> <div className="minimap-container" style={{ position: 'relative', borderColor: hitShake ? '#ef4444' : undefined, cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : undefined }}>
<div className="minimap-toolbar"> <div className="minimap-toolbar">
<button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.min(3, v.scale*1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}>+</button> <button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.min(3, v.scale*1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}>+</button>
<button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.max(0.5, v.scale/1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}></button> <button className="zoom-btn" onClick={() => setMinimapView(v => { const ns = Math.max(0.5, v.scale/1.5); return { x: v.x + 0.5*(160/v.scale - 160/ns), y: v.y + 0.5*(160/v.scale - 160/ns), scale: ns } })}></button>
<button className="zoom-btn" onClick={() => setMinimapView({ x: 0, y: 0, scale: 1 })}></button> <button className="zoom-btn" onClick={() => setMinimapView({ x: 0, y: 0, scale: 1 })}></button>
</div> </div>
<svg viewBox="0 0 160 160" className="minimap" <svg viewBox="0 0 160 160" className="minimap"
style={{ cursor: limbSelect ? 'default' : (minimapPanRef.current ? 'grabbing' : 'grab') }} style={{ cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : (minimapPanRef.current ? 'grabbing' : 'grab') }}
onMouseDown={e => { onMouseDown={e => {
if (e.button !== 0) return
minimapMovedRef.current = false
if (limbSelect) return if (limbSelect) return
minimapPanRef.current = true; minimapMovedRef.current = false minimapPanRef.current = true
minimapPanStart.current = { x: e.clientX, y: e.clientY, vx: minimapView.x, vy: minimapView.y } minimapPanStart.current = { x: e.clientX, y: e.clientY, vx: minimapView.x, vy: minimapView.y }
const svg = e.currentTarget const svg = e.currentTarget
const onMove = ev => { const onMove = ev => {
@@ -2496,7 +2602,7 @@ class MoveAction {
}} }}
onPointerLeave={() => setMinimapHover(null)} onPointerLeave={() => setMinimapHover(null)}
onWheel={e => { onWheel={e => {
if (limbSelect) return; e.preventDefault() if (!moveMode && !aimMode) return; e.preventDefault()
const d = e.deltaY > 0 ? 0.9 : 1.1 const d = e.deltaY > 0 ? 0.9 : 1.1
const r = e.currentTarget.getBoundingClientRect() const r = e.currentTarget.getBoundingClientRect()
setMinimapView(v => { setMinimapView(v => {
@@ -2506,12 +2612,29 @@ class MoveAction {
}} }}
onClick={e => { onClick={e => {
if (minimapMovedRef.current) { minimapMovedRef.current = false; return } if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
if (limbSelect || (!moveMode && !aimMode)) return if (!moveMode && !aimMode) return
const r = e.currentTarget.getBoundingClientRect() const r = e.currentTarget.getBoundingClientRect()
const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x const tx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x
const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y
if (aimMode) { if (aimMode) {
queueShot(tx, ty) if (selectedSkill !== 'Shoot' || limbSelect !== 'pistol') {
const cc = combatRef.current
const enemies = cc?.enemyIds ?? []
let closest = -1, closestDist = 30
enemies.forEach((eid, i) => {
const e = cc.characters[eid]
if (!e?.pos) return
const dx = tx - e.pos.x, dy = ty - e.pos.y
const dist = Math.sqrt(dx*dx + dy*dy)
if (dist < closestDist) { closestDist = dist; closest = i }
})
if (closest >= 0) {
setAttackEnemy(closest); setSelectedTarget(null)
setAimMode(false)
}
} else {
queueShot(tx, ty)
}
return return
} }
if (moveMode === 'turn') { if (moveMode === 'turn') {
@@ -2555,10 +2678,18 @@ class MoveAction {
<Fragment key={eid}> <Fragment key={eid}>
<circle cx={ex} cy={ey} r={5 * eSize} <circle cx={ex} cy={ey} r={5 * eSize}
fill={dead ? '#6b7280' : enemy.fillColor} stroke={dead ? '#6b7280' : '#e74c3c'} strokeWidth="1" opacity={dead ? 0.5 : 1} fill={dead ? '#6b7280' : enemy.fillColor} stroke={dead ? '#6b7280' : '#e74c3c'} strokeWidth="1" opacity={dead ? 0.5 : 1}
onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }); setHoveredChar(`enemy-${eid}`) }} onMouseEnter={e => { 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) }} onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }}
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 }) }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }} /> onMouseDown={e => {
e.stopPropagation()
if (e.button !== 0) return
if (aimMode && selectedSkill && limbSelect && limbSelect !== 'pistol') {
const cc = combatRef.current
const ei = cc?.enemyIds?.indexOf(eid) ?? -1
if (ei >= 0) { setAttackEnemy(ei); setSelectedTarget(null); setAimMode(false) }
}
}} />
</Fragment> </Fragment>
) )
})} })}
@@ -2576,7 +2707,7 @@ class MoveAction {
stroke="#22c55e" strokeWidth="1.5" strokeDasharray="3 3" opacity="0.7" /> stroke="#22c55e" strokeWidth="1.5" strokeDasharray="3 3" opacity="0.7" />
) )
})()} })()}
{aimMode && minimapHover && combat && (() => { {aimMode && minimapHover && combat && limbSelect === 'pistol' && (() => {
const actorId = currentActor ?? 'player' const actorId = currentActor ?? 'player'
const ch = combat.characters[actorId] const ch = combat.characters[actorId]
if (!ch?.pos) return null if (!ch?.pos) return null
@@ -2625,7 +2756,7 @@ class MoveAction {
cy={hitShake && hitShake.charId === cid ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y} cy={hitShake && hitShake.charId === cid ? ch.pos.y + (Math.random() - 0.5) * 1.5 : ch.pos.y}
r={4 * (ch.size ?? 1)} fill={chDead ? '#6b7280' : color} stroke={chDead ? '#6b7280' : '#3498db'} strokeWidth="1" opacity={chDead ? 0.5 : 1} r={4 * (ch.size ?? 1)} fill={chDead ? '#6b7280' : color} stroke={chDead ? '#6b7280' : '#3498db'} strokeWidth="1" opacity={chDead ? 0.5 : 1}
onMouseEnter={() => setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)} onMouseEnter={() => setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)}
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(cid) }} onClick={e => { e.stopPropagation() }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} /> onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} />
) )
}) })
@@ -2639,7 +2770,7 @@ class MoveAction {
cx={ch.pos.x} cy={ch.pos.y} cx={ch.pos.x} cy={ch.pos.y}
r={4} fill={ch.fillColor} stroke="#22c55e" strokeWidth="1" opacity={0.7} r={4} fill={ch.fillColor} stroke="#22c55e" strokeWidth="1" opacity={0.7}
onMouseEnter={() => setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)} onMouseEnter={() => setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)}
onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(nid) }} onClick={e => { e.stopPropagation() }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: nid }) }} /> onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: nid }) }} />
) )
})} })}
@@ -2782,174 +2913,7 @@ class MoveAction {
</div> </div>
</div> </div>
{/* ── attack modal */}
{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 (
<div className="inventory-overlay" onClick={cancelAttack}
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<div className="inventory-panel limb-select-panel" onClick={e => e.stopPropagation()}>
{(() => {
const weapon = weaponMap[limbSelect]
const weaponName = weapon?.name ?? limbSelect
const skills = weapon?.skills ?? []
return (
<div style={{ position: 'relative', minHeight: 520 }}>
<div className="inv-parts-title limb-select-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{weaponName} {actorChar?.name ?? actorId}</span>
{limbSelect === 'pistol' && <span style={{ fontSize: 13, color: '#9ca3af', fontWeight: 'normal' }}>Ammo: {actorChar?.weaponMagazines?.pistol?.length ?? 0}/{PISTOL_MAG} <span style={{ color: '#fbbf24' }}>({itemMap[actorChar?.weaponMagazines?.pistol?.[0]?.ammoType]?.name ?? 'none'})</span></span>}
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', paddingRight: 250 }}>
{/* skill list */}
{skills.length > 0 && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Skill</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{skills.map((skill, i) => (
<button key={i} className={'limb-target-btn' + (selectedSkill === skill.name ? ' limb-target-selected' : '')}
onClick={() => {
if (skill.name === 'Load') { startReload(); return }
if (skill.name === 'Unload') { unloadPistol(); return }
setSelectedSkill(skill.name); setAttackEnemy(null)
if (limbSelect === 'pistol') { setAimMode(true); setLimbSelect(null) }
}}
onMouseEnter={() => setHoveredSkill(skill.name)} onMouseLeave={() => setHoveredSkill(null)}>
<div style={{ fontWeight: 'bold' }}>{skill.name}</div>
</button>
))}
</div>
</div>
)}
{/* reload: ammo selection */}
{selectedSkill === 'Load' && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Ammo Type</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Object.entries(actorChar?.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize))
.length === 0
? <div style={{ color: '#6b7280', padding: 12 }}>No ammo available</div>
: Object.entries(actorChar?.itemCounts ?? {})
.filter(([id, qty]) => qty > 0 && (weaponMap.pistol?.compatibleBulletSizes ?? []).includes(itemMap[id]?.bulletSize))
.map(([id, qty]) => (
<div key={id} className="tree-node leaf" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 12px', cursor: 'pointer', borderRadius: 3, marginBottom: 4 }}
onMouseEnter={e => e.currentTarget.style.background = '#1e1e30'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => loadAmmoType(id)}>
<span style={{ fontWeight: 'bold' }}>{itemMap[id]?.name ?? id}</span>
<span style={{ color: '#9ca3af' }}>×{qty}</span>
</div>
))
}
</div>
</div>
)}
{/* enemy list (hidden during reload) */}
{selectedSkill && selectedSkill !== 'Load' && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Select Enemy</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{(combat?.enemyIds ?? []).map((eid, i) => {
const enemy = combat.characters[eid]
const inRange = canReach(actorPos, enemy.pos, limbSelect, selectedSkill)
return (
<button key={eid} className={'limb-target-btn' + (attackEnemy === i ? ' limb-target-selected' : '')}
onClick={() => setAttackEnemy(i)} style={{ opacity: inRange ? 1 : 0.4 }} disabled={!inRange}
onMouseEnter={() => setHoveredEnemy(i)} onMouseLeave={() => setHoveredEnemy(null)}>
<div style={{ fontWeight: 'bold' }}>{enemy.name}</div>
<div style={{ fontSize: 11, color: '#6b7280' }}>HP: {Object.values(enemy.integrity).reduce((a,b)=>a+b,0)}</div>
</button>
)
})}
</div>
</div>
)}
{/* limb selector (hidden during reload) */}
{selectedSkill !== 'Load' && attackEnemy !== null && (
<div>
<div className="inv-parts-title" style={{ marginBottom: 8 }}>Limb</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{[{ key:'torso',label:'Torso'},{ key:'head',label:'Head'},{ key:'larm',label:'Left Arm'},{ key:'rarm',label:'Right Arm'},{ key:'lleg',label:'Left Leg'},{ key:'rleg',label:'Right Leg'}].map(({key,label}) => (
<button key={key} className={'limb-target-btn' + (selectedTarget === (partMap[key]||key) ? ' limb-target-selected' : '')}
onClick={() => selectLimbTarget(key)} style={{ whiteSpace: 'nowrap', padding: '6px 16px' }}>
{label}
</button>
))}
</div>
</div>
)}
</div>
{/* side info panel */}
{skills.length > 0 && (
<div style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 220, background: '#1a1a2e', border: '1px solid #2d2d44', borderRadius: 6, padding: 14 }}>
{(() => {
if (!selectedSkill) {
const s = skills.find(x => x.name === hoveredSkill)
if (!s) return null
const descs = { blunt: 'Blunt force trauma', slash: 'Slashing cut', pierce: 'Piercing strike', shove: 'Knocks the target back' }
return (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#fbbf24', fontWeight: 'bold', fontSize: 14 }}>{s.name}</div>
<div style={{ color: '#9ca3af', fontSize: 12, marginTop: 4 }}>{descs[s.damageType] || s.damageType}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>Damage: </span><span style={{ color: '#ef4444' }}>{Math.round((weapon?.damage ?? 0) * s.mult)}</span></div>
<div><span style={{ color: '#6b7280' }}>Multiplier: </span><span>{s.mult}x</span></div>
<div><span style={{ color: '#6b7280' }}>Type: </span><span style={{ color: '#a78bfa' }}>{s.damageType}</span></div>
<div><span style={{ color: '#6b7280' }}>Range: </span><span>{s.range}</span></div>
</div>
</>)
}
if (attackEnemy === null) {
const e = hoveredEnemy !== null && combat ? combat.characters[combat.enemyIds[hoveredEnemy]] : null
if (!e) return null
return (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#22c55e', fontWeight: 'bold', fontSize: 14 }}>{e.name}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>HP: </span><span style={{ color: '#ef4444' }}>{Object.values(e.integrity).reduce((a,b)=>a+b,0)}</span></div>
{Object.entries(e.integrity).map(([p,v]) => <div key={p}><span style={{ color: '#6b7280' }}>{bodyPartLabels[p]}: </span><span>{v}</span></div>)}
</div>
</>)
}
const e = combat ? combat.characters[combat.enemyIds[attackEnemy]] : null
const limbKey = selectedTarget
const hp = limbKey && e ? e.integrity[limbKey] : null
return (<>
<div style={{ background: '#16162a', border: '1px solid #2d2d44', borderRadius: 4, padding: '8px 12px', marginBottom: 12 }}>
<div style={{ color: '#fbbf24', fontWeight: 'bold', fontSize: 14 }}>{e?.name || ''}</div>
<div style={{ color: '#9ca3af', fontSize: 12, marginTop: 4 }}>Target: {selectedTarget ? bodyPartLabels[selectedTarget] : 'None'}</div>
</div>
<div style={{ fontSize: 13, color: '#d1d5db', lineHeight: 2 }}>
<div><span style={{ color: '#6b7280' }}>Limb HP: </span><span style={{ color: '#ef4444' }}>{hp !== null ? hp : '-'}</span></div>
</div>
</>)
})()}
</div>
)}
</div>
)
})()}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{limbSelect !== 'pistol' && <button className="limb-confirm-btn" onClick={executeAttack}
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</button>}
<button className="limb-cancel-btn" onClick={cancelAttack} style={{ width: 'auto', padding: '10px 24px' }}>Cancel</button>
</div>
</div>
)
})()}
</> </>
)} )}
@@ -3459,6 +3423,7 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
{/* ═══════════════════════════════════════════════════════ CONTEXT MENU */} {/* ═══════════════════════════════════════════════════════ CONTEXT MENU */}
{contextMenu && ( {contextMenu && (
<div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={() => { setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}> <div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={() => { setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(contextMenu.charId); setContextMenu(null) }}>Inspect</button>
<button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button> <button className={`context-menu-btn${canTrade(contextMenu.charId) ? '' : ' disabled'}`} disabled={!canTrade(contextMenu.charId)} onClick={e => { e.stopPropagation(); if (canTrade(contextMenu.charId)) startTrade(contextMenu.charId) }}>Trade</button>
</div> </div>
)} )}