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-radius: 3px;
overflow: hidden;
user-select: none;
}
.minimap-container svg {
width: 100%;
height: 100%;
display: block;
user-select: none;
-webkit-user-select: none;
}
.combat-log {
+171 -66
View File
@@ -251,7 +251,7 @@ function computeMove(from, to, speed, blocks) {
const nx = cx + (dx / dist) * thisStep
const ny = cy + (dy / dist) * thisStep
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 }
}
@@ -324,7 +324,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' }) {
function buildPlayerAttack({ weaponId, skillName, targetPart, enemyIndex, 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
@@ -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 { dmg, severity, injType, injLabel } = computeHit({ weaponId, skill, weaponConditions, bothMult })
const enemyId = combat.enemyIds[enemyIndex]
const enemy = enemyId ? combat.characters[enemyId] : null
const enemy = targetCharId ? combat.characters[targetCharId] : (enemyId ? combat.characters[enemyId] : null)
return {
type: 'attack',
actor,
charId,
enemyCharId: enemyId,
enemyCharId: targetCharId || enemyId,
targetCharId,
weaponId, skillName, targetPart, enemyIndex,
dmg, severity, injType, injLabel,
attackerParts: { ...bodyParts },
@@ -639,7 +640,16 @@ export default function App() {
const minimapPanRef = useRef(false)
const minimapPanStart = useRef({ x: 0, y: 0 })
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 pannedKeysRef = useRef(new Set())
const panRafRef = useRef(null)
// ─────────────────────────────────────────────────────────────────────────
// DERIVED
@@ -1011,6 +1021,56 @@ export default function App() {
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.
* Returns { x, y } or null if the type is unknown.
@@ -1638,6 +1698,33 @@ class MoveAction {
return () => cancelAnimationFrame(frame)
}, [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
// ─────────────────────────────────────────────────────────────────────────
@@ -1934,15 +2021,40 @@ class MoveAction {
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(() => {
const onKey = (e) => {
if (e.code === 'Space' && combat) { e.preventDefault(); toggleTimer() }
if (e.key === 'Escape' && aimMode) { setAimMode(false); setSelectedSkill(null) }
let panRaf
const tick = () => {
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)
return () => window.removeEventListener('keydown', onKey)
}, [aimMode, combat])
}
const onKeyDown = (e) => {
if (e.code === 'Space' && combatRef.current) { e.preventDefault(); toggleTimer() }
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. */
const startMove = (targetX, targetY) => {
@@ -2254,7 +2366,7 @@ class MoveAction {
<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
Click an enemy dot on the minimap to target
</div>
) : (
<>
@@ -2569,12 +2681,8 @@ class MoveAction {
{/* ── center: minimap + log */}
<div className="combat-center">
<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>
<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"
<svg ref={minimapSvgRef} viewBox="0 0 160 160" className="minimap"
style={{ cursor: aimMode && limbSelect === 'pistol' ? 'crosshair' : (minimapPanRef.current ? 'grabbing' : 'grab') }}
onMouseDown={e => {
if (e.button !== 0) return
@@ -2596,48 +2704,38 @@ class MoveAction {
onPointerMove={e => {
if (minimapPanRef.current) return
const r = e.currentTarget.getBoundingClientRect()
const mx = ((e.clientX - r.left) / r.width) * 160 / minimapView.scale + minimapView.x
const my = ((e.clientY - r.top) / r.height) * 160 / minimapView.scale + minimapView.y
const mv = minimapViewRef.current
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 })
}}
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 => {
if (minimapMovedRef.current) { minimapMovedRef.current = false; return }
if (!moveMode && !aimMode) return
if (!moveModeRef.current && !aimModeRef.current) return
const r = e.currentTarget.getBoundingClientRect()
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
if (aimMode) {
if (selectedSkill !== 'Shoot' || limbSelect !== 'pistol') {
const mv = minimapViewRef.current
const tx = ((e.clientX - r.left) / r.width) * 160 / mv.scale + mv.x
const ty = ((e.clientY - r.top) / r.height) * 160 / mv.scale + mv.y
if (aimModeRef.current) {
if (selectedSkillRef.current !== 'Shoot' || limbSelectRef.current !== '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 allCharIds = [...(cc?.enemyIds ?? []), ...(cc?.npcIds ?? []), ...(cc?.partyIds ?? [cc?.playerEntityId].filter(Boolean))]
let closest = null, closestDist = 20
allCharIds.forEach(cid => {
const ch = cc.characters[cid]
if (!ch?.pos) return
const dx = tx - ch.pos.x, dy = ty - ch.pos.y
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) {
setAttackEnemy(closest); setSelectedTarget(null)
setAimMode(false)
}
if (closest) attackCharTarget(closest)
} else {
queueShot(tx, ty)
}
return
}
if (moveMode === 'turn') {
if (moveModeRef.current === 'turn') {
const turnActor = currentActor ?? 'player'
const pp = combat.characters[turnActor]?.pos ?? combat.characters['player'].pos
const dx = tx - pp.x
@@ -2661,34 +2759,39 @@ class MoveAction {
</filter>
</defs>
<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',
transition: hitShake ? 'none' : undefined,
}}>
<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?.enemyIds ?? []).map(eid => {
{(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.
const isTargetable = aimMode && !dead && (selectedSkill !== 'Shoot' || limbSelect !== 'pistol')
const isCurrentTarget = attackEnemy === idx
return (
<Fragment key={eid}>
<circle cx={ex} cy={ey} r={5 * eSize}
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}`) }}
onMouseLeave={() => { setHoverInfo(null); setHoveredChar(null) }}
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}
onMouseDown={e => {
fill={dead ? '#6b7280' : enemy.fillColor}
stroke={dead ? '#6b7280' : (hoveredChar === `enemy-${eid}` ? '#ffffff' : (isCurrentTarget ? '#fbbf24' : '#e74c3c'))}
strokeWidth={dead ? 1 : (hoveredChar === `enemy-${eid}` || isCurrentTarget ? 1.25 : 1)}
opacity={dead ? 0.5 : 1}
style={{ cursor: isTargetable && limbSelect === 'pistol' ? 'crosshair' : isTargetable ? 'pointer' : undefined, pointerEvents: isTargetable ? 'auto' : 'none' }}
onMouseEnter={() => setHoveredChar(`enemy-${eid}`)}
onMouseLeave={() => setHoveredChar(null)}
onClick={e => {
if (!isTargetable) return
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) }
}
attackCharTarget(eid)
}} />
</Fragment>
)
@@ -2696,7 +2799,7 @@ class MoveAction {
{combat && selectedSkill && limbSelect && (() => {
const rangeActor = currentActor ?? 'player'
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 && (() => {
const actorId = currentActor ?? 'player'
@@ -2754,9 +2857,9 @@ class MoveAction {
<circle key={`party-${cid}`}
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}
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)}
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 }) }} />
)
})
@@ -2768,9 +2871,9 @@ class MoveAction {
return (
<circle key={`npc-${nid}`}
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)}
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 }) }} />
)
})}
@@ -2904,7 +3007,7 @@ class MoveAction {
const enemy = combat.characters[eid]
const idx = combat.enemyIds.indexOf(eid)
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>
</div>
)
@@ -3422,10 +3525,12 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul
{/* ═══════════════════════════════════════════════════════ CONTEXT MENU */}
{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) }}>
<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" 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>
)}
{/* ═══════════════════════════════════════════════════════ NOTIFICATIONS */}