targeting fixed

This commit is contained in:
2026-06-19 03:19:54 -04:00
parent 2ff7932048
commit d9f45f71ff
2 changed files with 178 additions and 70 deletions
+3
View File
@@ -330,12 +330,15 @@ body {
border: 2px solid #5F71C5; border: 2px solid #5F71C5;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
user-select: none;
} }
.minimap-container svg { .minimap-container svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
user-select: none;
-webkit-user-select: none;
} }
.combat-log { .combat-log {
+175 -70
View File
@@ -251,7 +251,7 @@ function computeMove(from, to, speed, blocks) {
const nx = cx + (dx / dist) * thisStep const nx = cx + (dx / dist) * thisStep
const ny = cy + (dy / dist) * thisStep const ny = cy + (dy / dist) * thisStep
if (blk.some(b => rectCollides(nx, ny, b))) break if (blk.some(b => rectCollides(nx, ny, b))) break
cx = nx; cy = ny cx = Math.max(5, Math.min(145, nx)); cy = Math.max(5, Math.min(145, ny))
} }
return { x: cx, y: cy } return { x: cx, y: cy }
} }
@@ -324,7 +324,7 @@ function canReach(attackerPos, targetPos, weaponId, skillName) {
* Build a player attack action for the action queue. * Build a player attack action for the action queue.
* Returns an action object that actionHandlers.attack can process. * Returns an action object that actionHandlers.attack can process.
*/ */
function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player' }) { function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equippedWeapons, weaponConditions, bodyParts, bodyInjuries, combat, actor = 'player', charId = 'player', targetCharId }) {
const base = weaponMap[weaponId] const base = weaponMap[weaponId]
const isArm = weaponId === 'left_arm' || weaponId === 'right_arm' const isArm = weaponId === 'left_arm' || weaponId === 'right_arm'
const skill = isArm const skill = isArm
@@ -338,13 +338,14 @@ function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, equipp
const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0 const bothMult = (!isArm && equippedWeapons.left === weaponId && equippedWeapons.right === weaponId) ? 1.5 : 1.0
const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult }) const { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult })
const enemyId = combat.enemyIds[enemyIndex] const enemyId = combat.enemyIds[enemyIndex]
const enemy = enemyId ? combat.characters[enemyId] : null const enemy = targetCharId ? combat.characters[targetCharId] : (enemyId ? combat.characters[enemyId] : null)
return { return {
type: 'attack', type: 'attack',
actor, actor,
charId, charId,
enemyCharId: enemyId, enemyCharId: targetCharId || enemyId,
targetCharId,
weaponId, skillName, targetPart, enemyIndex, weaponId, skillName, targetPart, enemyIndex,
dmg, severity, injType, injLabel, dmg, severity, injType, injLabel,
attackerParts: { ...bodyParts }, attackerParts: { ...bodyParts },
@@ -639,7 +640,16 @@ export default function App() {
const minimapPanRef = useRef(false) const minimapPanRef = useRef(false)
const minimapPanStart = useRef({ x: 0, y: 0 }) const minimapPanStart = useRef({ x: 0, y: 0 })
const minimapMovedRef = useRef(false) const minimapMovedRef = useRef(false)
const minimapSvgRef = useRef(null)
const moveModeRef = useRef(null); moveModeRef.current = moveMode
const aimModeRef = useRef(false); aimModeRef.current = aimMode
const selectedSkillRef = useRef(null); selectedSkillRef.current = selectedSkill
const limbSelectRef = useRef(null); limbSelectRef.current = limbSelect
const currentActorRef = useRef(null); currentActorRef.current = currentActor
const minimapViewRef = useRef({ x: 0, y: 0, scale: 1 }); minimapViewRef.current = minimapView
const replayElapsedRef = useRef(0) const replayElapsedRef = useRef(0)
const pannedKeysRef = useRef(new Set())
const panRafRef = useRef(null)
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// DERIVED // DERIVED
@@ -1011,6 +1021,56 @@ export default function App() {
setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]) setLimbSelect(null); setMoveMode(null); setAimMode(false); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([])
} }
/**
* Queue a melee attack on a character ID (enemy, NPC, or party).
*/
const attackCharTarget = (charId) => {
const cc = combatRef.current
if (!cc) return
const target = cc.characters[charId]
if (!target || isDead(target.integrity)) return
const actorId = currentActorRef.current ?? 'player'
const actorChar = cc.characters[actorId]
if (!actorChar || isDead(actorChar.integrity)) return
const weaponId = limbSelectRef.current
const skillName = selectedSkillRef.current
if (!weaponId || !skillName) return
const actorPos = characterPositionsRef.current[actorId] ?? actorChar.pos
if (!canReach(actorPos, target.pos, weaponId, skillName)) {
setCombat(c => ({ ...c, log: [...c.log, `Too far`] }))
return
}
const targetPart = 'torso'
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,
bodyInjuries: actorChar.injuries,
combat: cc,
actor: actorId === 'player' ? 'player' : 'party',
charId: actorId,
targetCharId: charId,
})
if (action.type === 'shove_pending') {
const enemy2 = cc.characters[action.enemyCharId]
if (!enemy2 || isDead(enemy2.integrity)) return
const { pos, actualPush } = computeShove(actorPos, enemy2.pos, cc.blocks)
setCombat(c => ({
...c,
characters: { ...c.characters, [action.enemyCharId]: { ...enemy2, pos } },
log: [...c.log, `${actorChar.name} shoves ${enemy2.name} back ${Math.round(actualPush)} units!`],
}))
setLimbSelect(null); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]); setAimMode(false)
return
}
const anim = createWeaponStrikeAnim(action, cc)
characterActionsRef.current[actorId] = anim
setLimbSelect(null); setSelectedSkill(null); setCurrentActor(null); setSkillDropdownOpen([]); setAimMode(false)
}
/** /**
* Dispatch: get the current position of any animation type. * Dispatch: get the current position of any animation type.
* Returns { x, y } or null if the type is unknown. * Returns { x, y } or null if the type is unknown.
@@ -1638,6 +1698,33 @@ class MoveAction {
return () => cancelAnimationFrame(frame) return () => cancelAnimationFrame(frame)
}, [zoomAnim]) }, [zoomAnim])
// ── minimap wheel zoom (non-passive to allow preventDefault)
useEffect(() => {
const el = minimapSvgRef.current
if (!el) return
const handler = e => {
if (!moveModeRef.current && !aimModeRef.current) return
e.preventDefault()
const d = e.deltaY > 0 ? 0.9 : 1.1
const r = el.getBoundingClientRect()
setMinimapView(v => {
const ns = Math.max(0.5, Math.min(3, v.scale * d))
return { x: v.x + (e.clientX - r.left) / r.width * (160/v.scale - 160/ns), y: v.y + (e.clientY - r.top) / r.height * (160/v.scale - 160/ns), scale: ns }
})
}
el.addEventListener('wheel', handler, { passive: false })
return () => el.removeEventListener('wheel', handler)
}, [setMinimapView])
// NOTE: the previous native `click` listener on the minimap SVG (registered
// via addEventListener with an empty dependency array) was removed here.
// It duplicated the React `onClick` handler on the same <svg> element and
// fired on the same click event, racing against it via refs that only
// update on render (aimModeRef, selectedSkillRef, limbSelectRef, etc).
// That race was the root cause of melee-target clicks on the minimap
// sometimes doing nothing. The React onClick on the <svg> below — plus the
// new direct onClick on each enemy <circle> — now fully own this behavior.
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// REPLAY // REPLAY
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -1934,15 +2021,40 @@ class MoveAction {
if (timerActiveRef.current) { stopTimer() } else { startTimer() } if (timerActiveRef.current) { stopTimer() } else { startTimer() }
} }
// Spacebar toggles timer, Escape cancels aim mode // Spacebar toggles timer (if in combat), Escape cancels aim mode, WASD pans minimap
useEffect(() => { useEffect(() => {
const onKey = (e) => { let panRaf
if (e.code === 'Space' && combat) { e.preventDefault(); toggleTimer() } const tick = () => {
if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) } panRaf = requestAnimationFrame(tick)
const pk = pannedKeysRef.current
if (pk.size === 0) return
let panX = 0, panY = 0
if (pk.has('a')) panX -= 1
if (pk.has('d')) panX += 1
if (pk.has('w')) panY -= 1
if (pk.has('s')) panY += 1
const len = Math.sqrt(panX * panX + panY * panY)
if (len > 0) {
setMinimapView(v => ({ ...v, x: v.x + (panX / len) * 100 * 0.016 / v.scale, y: v.y + (panY / len) * 100 * 0.016 / v.scale }))
}
} }
window.addEventListener('keydown', onKey) const onKeyDown = (e) => {
return () => window.removeEventListener('keydown', onKey) if (e.code === 'Space' && combatRef.current) { e.preventDefault(); toggleTimer() }
}, [aimMode, combat]) if (e.key === 'Escape' && aimModeRef.current) { setAimMode(false); setSelectedSkill(null) }
const k = e.key.toLowerCase()
if (!'wasd'.includes(k)) return
e.preventDefault()
pannedKeysRef.current.add(k)
if (!panRaf) panRaf = requestAnimationFrame(tick)
}
const onKeyUp = (e) => {
pannedKeysRef.current.delete(e.key.toLowerCase())
if (pannedKeysRef.current.size === 0 && panRaf) { cancelAnimationFrame(panRaf); panRaf = null }
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => { cancelAnimationFrame(panRaf); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp) }
}, [])
/** Execute a player move; queue it for execution. */ /** Execute a player move; queue it for execution. */
const startMove = (targetX, targetY) => { const startMove = (targetX, targetY) => {
@@ -2254,7 +2366,7 @@ class MoveAction {
<div className="tree-children"> <div className="tree-children">
{attackEnemy === null ? ( {attackEnemy === null ? (
<div className="tree-node leaf" style={{ cursor: 'default', color: '#9ca3af', fontSize: 12, fontStyle: 'italic' }}> <div className="tree-node leaf" style={{ cursor: 'default', color: '#9ca3af', fontSize: 12, fontStyle: 'italic' }}>
Click the minimap to target Click an enemy dot on the minimap to target
</div> </div>
) : ( ) : (
<> <>
@@ -2569,12 +2681,8 @@ 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 && limbSelect === 'pistol' ? 'crosshair' : undefined }}> <div className="minimap-container" style={{ position: 'relative', borderColor: hitShake ? '#ef4444' : undefined, cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : undefined }}>
<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> <svg ref={minimapSvgRef} viewBox="0 0 160 160" className="minimap"
<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>
</div>
<svg viewBox="0 0 160 160" className="minimap"
style={{ cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : (minimapPanRef.current ? 'grabbing' : 'grab') }} style={{ cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : (minimapPanRef.current ? 'grabbing' : 'grab') }}
onMouseDown={e => { onMouseDown={e => {
if (e.button !== 0) return if (e.button !== 0) return
@@ -2596,48 +2704,38 @@ class MoveAction {
onPointerMove={e => { onPointerMove={e => {
if (minimapPanRef.current) return if (minimapPanRef.current) return
const r = e.currentTarget.getBoundingClientRect() const r = e.currentTarget.getBoundingClientRect()
const mx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x const mv = minimapViewRef.current
const my = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y const mx = ((e.clientX - r.left) / r.width) * 160 / mv.scale + mv.x
const my = ((e.clientY - r.top) / r.height) * 160 / mv.scale + mv.y
setMinimapHover({ x: mx, y: my }) setMinimapHover({ x: mx, y: my })
}} }}
onPointerLeave={() => setMinimapHover(null)} onPointerLeave={() => setMinimapHover(null)}
onWheel={e => {
if (!moveMode && !aimMode) return; e.preventDefault()
const d = e.deltaY > 0 ? 0.9 : 1.1
const r = e.currentTarget.getBoundingClientRect()
setMinimapView(v => {
const ns = Math.max(0.5, Math.min(3, v.scale * d))
return { x: v.x + (e.clientX - r.left) / r.width * (160/v.scale - 160/ns), y: v.y + (e.clientY - r.top) / r.height * (160/v.scale - 160/ns), scale: ns }
})
}}
onClick={e => { onClick={e => {
if (minimapMovedRef.current) { minimapMovedRef.current = false; return } if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
if (!moveMode && !aimMode) return if (!moveModeRef.current && !aimModeRef.current) 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 mv = minimapViewRef.current
const ty = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y const tx = ((e.clientX - r.left) / r.width) * 160 / mv.scale + mv.x
if (aimMode) { const ty = ((e.clientY - r.top) / r.height) * 160 / mv.scale + mv.y
if (selectedSkill !== 'Shoot' || limbSelect !== 'pistol') { if (aimModeRef.current) {
if (selectedSkillRef.current !== 'Shoot' || limbSelectRef.current !== 'pistol') {
const cc = combatRef.current const cc = combatRef.current
const enemies = cc?.enemyIds ?? [] const allCharIds = [...(cc?.enemyIds ?? []), ...(cc?.npcIds ?? []), ...(cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean))]
let closest = -1, closestDist = 30 let closest = null, closestDist = 20
enemies.forEach((eid, i) => { allCharIds.forEach(cid => {
const e = cc.characters[eid] const ch = cc.characters[cid]
if (!e?.pos) return if (!ch?.pos) return
const dx = tx - e.pos.x, dy = ty - e.pos.y const dx = tx - ch.pos.x, dy = ty - ch.pos.y
const dist = Math.sqrt(dx*dx + dy*dy) const dist = Math.sqrt(dx*dx + dy*dy)
if (dist < closestDist) { closestDist = dist; closest = i } if (dist < closestDist) { closestDist = dist; closest = cid }
}) })
if (closest >= 0) { if (closest) attackCharTarget(closest)
setAttackEnemy(closest); setSelectedTarget(null)
setAimMode(false)
}
} else { } else {
queueShot(tx, ty) queueShot(tx, ty)
} }
return return
} }
if (moveMode === 'turn') { if (moveModeRef.current === 'turn') {
const turnActor = currentActor ?? 'player' const turnActor = currentActor ?? 'player'
const pp = combat.characters[turnActor]?.pos ?? combat.characters['player'].pos const pp = combat.characters[turnActor]?.pos ?? combat.characters['player'].pos
const dx = tx - pp.x const dx = tx - pp.x
@@ -2661,42 +2759,47 @@ class MoveAction {
</filter> </filter>
</defs> </defs>
<g style={{ <g style={{
transform: `translate(${-minimapView.x * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y * minimapView.scale + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`, transform: `translate(${-minimapView.x + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px, ${-minimapView.y + (hitShake ? (Math.random() - 0.5) * 2 : 0)}px) scale(${minimapView.scale})`,
transformOrigin: '0 0', transformOrigin: '0 0',
transition: hitShake ? 'none' : undefined, transition: hitShake ? 'none' : undefined,
}}> }}>
<rect x="0" y="0" width="160" height="160" fill="#171721" /> <rect x="0" y="0" width="160" height="160" fill="#171721" />
{(combat?.blocks ?? []).map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)} {(combat?.blocks ?? []).map((block, i) => <rect key={i} x={block.x} y={block.y} width={block.w} height={block.h} fill="#2e303a" stroke="#1f2028" strokeWidth="1" />)}
{(combat?.enemyIds ?? []).map(eid => { {(combat?.enemyIds ?? []).map((eid, idx) => {
const enemy = combat.characters[eid] const enemy = combat.characters[eid]
const dead = isDead(enemy.integrity) const dead = isDead(enemy.integrity)
const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy' const isShaking = hitShake && hitShake.charId === eid && hitShake.actor === 'enemy'
const eSize = enemy.size ?? 1 const eSize = enemy.size ?? 1
const ex = isShaking ? enemy.pos.x + (Math.random() - 0.5) * 1.5 : enemy.pos.x 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 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.
const isTargetable = aimMode && !dead && (selectedSkill !== 'Shoot' || limbSelect !== 'pistol')
const isCurrentTarget = attackEnemy === idx
return ( return (
<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}
onMouseEnter={e => { e.stopPropagation(); setHoverInfo({ type: 'enemy', name: enemy.name, attack: enemy.attack, speed: enemy.speed, defeated: isDead(enemy.integrity) }); setHoveredChar(`enemy-${eid}`) }} stroke={dead ? '#6b7280' : (hoveredChar === `enemy-${eid}` ? '#ffffff' : (isCurrentTarget ? '#fbbf24' : '#e74c3c'))}
onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }} strokeWidth={dead ? 1 : (hoveredChar === `enemy-${eid}` || isCurrentTarget ? 1.25 : 1)}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }} opacity={dead ? 0.5 : 1}
onMouseDown={e => { style={{ cursor: isTargetable && limbSelect === 'pistol' ? 'crosshair' : isTargetable ? 'pointer' : undefined, pointerEvents: isTargetable ? 'auto' : 'none' }}
e.stopPropagation() onMouseEnter={() => setHoveredChar(`enemy-${eid}`)}
if (e.button !== 0) return onMouseLeave={() => setHoveredChar(null)}
if (aimMode && selectedSkill && limbSelect && limbSelect !== 'pistol') { onClick={e => {
const cc = combatRef.current if (!isTargetable) return
const ei = cc?.enemyIds?.indexOf(eid) ?? -1 e.stopPropagation()
if (ei >= 0) { setAttackEnemy(ei); setSelectedTarget(null); setAimMode(false) } attackCharTarget(eid)
} }} />
}} />
</Fragment> </Fragment>
) )
})} })}
{combat && selectedSkill && limbSelect && (() => { {combat && selectedSkill && limbSelect && (() => {
const rangeActor = currentActor ?? 'player' const rangeActor = currentActor ?? 'player'
const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos
return <circle cx={rp.x} cy={rp.y} r={getSkillRange(limbSelect, selectedSkill)} fill="none" stroke="rgba(52,152,219,0.5)" strokeWidth="1" strokeDasharray="4 4" /> return <circle cx={rp.x} cy={rp.y} r={getSkillRange(limbSelect, selectedSkill)} fill="none" stroke="rgba(52,152,219,0.5)" strokeWidth="1" strokeDasharray="4 4" pointerEvents="none" />
})()} })()}
{moveMode && moveMode !== 'turn' && minimapHover && combat && (() => { {moveMode && moveMode !== 'turn' && minimapHover && combat && (() => {
const actorId = currentActor ?? 'player' const actorId = currentActor ?? 'player'
@@ -2754,9 +2857,9 @@ class MoveAction {
<circle key={`party-${cid}`} <circle key={`party-${cid}`}
cx={hitShake && hitShake.charId === cid ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x} cx={hitShake && hitShake.charId === cid ? ch.pos.x + (Math.random() - 0.5) * 1.5 : ch.pos.x}
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' : (hoveredChar === cid ? '#ffffff' : '#3498db')} strokeWidth={hoveredChar === cid ? 1.25 : 1} opacity={chDead ? 0.5 : 1}
onMouseEnter={() => setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)} onMouseEnter={() => setHoveredChar(cid)} onMouseLeave={() => setHoveredChar(null)}
onClick={e => { e.stopPropagation() }} 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 }) }} /> onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: cid }) }} />
) )
}) })
@@ -2768,9 +2871,9 @@ class MoveAction {
return ( return (
<circle key={`npc-${nid}`} <circle key={`npc-${nid}`}
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={hoveredChar === nid ? '#ffffff' : '#22c55e'} strokeWidth={hoveredChar === nid ? 1.25 : 1} opacity={hoveredChar === nid ? 1 : 0.7}
onMouseEnter={() => setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)} onMouseEnter={() => setHoveredChar(nid)} onMouseLeave={() => setHoveredChar(null)}
onClick={e => { e.stopPropagation() }} 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 }) }} /> onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: nid }) }} />
) )
})} })}
@@ -2904,7 +3007,7 @@ class MoveAction {
const enemy = combat.characters[eid] const enemy = combat.characters[eid]
const idx = combat.enemyIds.indexOf(eid) const idx = combat.enemyIds.indexOf(eid)
return ( return (
<div key={eid} className="enemy-entry" onClick={() => { setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}> <div key={eid} className="enemy-entry" onClick={() => { 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 }) }}>
<span className="enemy-name">{enemy.name}</span> <span className="enemy-name">{enemy.name}</span>
</div> </div>
) )
@@ -3422,9 +3525,11 @@ 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="backdrop" style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setContextMenu(null)} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}>
<button className="context-menu-btn" onClick={e => { e.stopPropagation(); setShowInventory(true); setPanelTarget(contextMenu.charId); setContextMenu(null) }}>Inspect</button> <div className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y, position: 'absolute' }} onClick={e => { e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}>
<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" 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>
</div>
</div> </div>
)} )}