From d9f45f71ffbca904a423943d072b198096f207cc Mon Sep 17 00:00:00 2001 From: johnruina Date: Fri, 19 Jun 2026 03:19:54 -0400 Subject: [PATCH] targeting fixed --- src/App.css | 3 + src/App.jsx | 245 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 178 insertions(+), 70 deletions(-) diff --git a/src/App.css b/src/App.css index 494a732..b23a195 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.jsx b/src/App.jsx index 1e96fd1..ff49b55 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 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 below — plus the + // new direct onClick on each enemy — 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 {
{attackEnemy === null ? (
- Click the minimap to target + Click an enemy dot on the minimap to target
) : ( <> @@ -2569,12 +2681,8 @@ class MoveAction { {/* ── center: minimap + log */}
-
- - - -
- { 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,42 +2759,47 @@ class MoveAction { {(combat?.blocks ?? []).map((block, i) => )} - {(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 ( { 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 => { - 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) } - } - }} /> + 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() + attackCharTarget(eid) + }} /> ) })} {combat && selectedSkill && limbSelect && (() => { const rangeActor = currentActor ?? 'player' const rp = combat.characters[rangeActor]?.pos ?? combat.characters['player'].pos - return + return })()} {moveMode && moveMode !== 'turn' && minimapHover && combat && (() => { const actorId = currentActor ?? 'player' @@ -2754,9 +2857,9 @@ class MoveAction { 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 ( 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 ( -
{ setSelectedEnemy(idx); setShowInventory(true); setPanelTarget(eid) }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, charId: eid }) }}> +
{ 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}
) @@ -3422,9 +3525,11 @@ onContextMenu={e => { e.preventDefault(); setHoverInfo(null); setHoveredChar(nul {/* ═══════════════════════════════════════════════════════ CONTEXT MENU */} {contextMenu && ( -
{ setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}> - - +
setContextMenu(null)} onContextMenu={e => { e.preventDefault(); setContextMenu(null) }}> +
{ e.stopPropagation(); setHoverInfo(null); setHoveredChar(null); setContextMenu(null) }}> + + +
)}