Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/coaster/CoasterGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const TILE_HEIGHT = TILE_WIDTH * HEIGHT_RATIO;
const ZOOM_MIN = 0.3;
const ZOOM_MAX = 2.5;
const HEIGHT_UNIT = 20;
const KEY_PAN_SPEED = 520; // pixels per second (same as city game)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// Water texture path (same as city game)
const WATER_ASSET_PATH = '/assets/water.png';
Expand Down Expand Up @@ -2511,6 +2512,7 @@ export function CoasterGrid({
const initialPinchDistanceRef = useRef<number | null>(null);
const initialZoomRef = useRef(1);
const lastTouchCenterRef = useRef<{ x: number; y: number } | null>(null);
const keysPressedRef = useRef<Set<string>>(new Set());
const [hoveredTile, setHoveredTile] = useState<{ x: number; y: number } | null>(null);
const [canvasSize, setCanvasSize] = useState({ width: 800, height: 600 });
const [spriteSheets, setSpriteSheets] = useState<Map<string, HTMLCanvasElement>>(new Map());
Expand Down Expand Up @@ -2599,6 +2601,78 @@ export function CoasterGrid({
useEffect(() => {
onViewportChange?.({ offset, zoom, canvasSize });
}, [offset, zoom, canvasSize, onViewportChange]);

// Keyboard panning (WASD / arrow keys) - same as city game
useEffect(() => {
const pressed = keysPressedRef.current;
const isTypingTarget = (target: EventTarget | null) => {
const el = target as HTMLElement | null;
return !!el?.closest('input, textarea, select, [contenteditable="true"]');
};

const handleKeyDown = (e: KeyboardEvent) => {
if (isTypingTarget(e.target)) return;
const key = e.key.toLowerCase();
if (['w', 'a', 's', 'd', 'arrowup', 'arrowleft', 'arrowdown', 'arrowright'].includes(key)) {
pressed.add(key);
e.preventDefault();
}
};

const handleKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
pressed.delete(key);
};

let animationFrameId = 0;
let lastTime = performance.now();

const tick = (time: number) => {
animationFrameId = requestAnimationFrame(tick);
const delta = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
if (!pressed.size) return;

let dx = 0;
let dy = 0;
if (pressed.has('w') || pressed.has('arrowup')) dy += KEY_PAN_SPEED * delta;
if (pressed.has('s') || pressed.has('arrowdown')) dy -= KEY_PAN_SPEED * delta;
if (pressed.has('a') || pressed.has('arrowleft')) dx += KEY_PAN_SPEED * delta;
if (pressed.has('d') || pressed.has('arrowright')) dx -= KEY_PAN_SPEED * delta;

if (dx !== 0 || dy !== 0) {
const n = latestStateRef.current.gridSize;
const currentZoom = zoom;
const cs = canvasSize;
// Calculate bounds inline
const padding = 100;
const mapLeft = -(n - 1) * TILE_WIDTH / 2;
const mapRight = (n - 1) * TILE_WIDTH / 2;
const mapTop = 0;
const mapBottom = (n - 1) * TILE_HEIGHT;
const minOffsetX = padding - mapRight * currentZoom;
const maxOffsetX = cs.width - padding - mapLeft * currentZoom;
const minOffsetY = padding - mapBottom * currentZoom;
const maxOffsetY = cs.height - padding - mapTop * currentZoom;

setOffset(prev => ({
x: Math.max(minOffsetX, Math.min(maxOffsetX, prev.x + dx)),
y: Math.max(minOffsetY, Math.min(maxOffsetY, prev.y + dy)),
}));
}
};

window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
animationFrameId = requestAnimationFrame(tick);
Comment thread
cursor[bot] marked this conversation as resolved.

return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
cancelAnimationFrame(animationFrameId);
pressed.clear();
};
}, [zoom, canvasSize, latestStateRef]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyboard panning stops when zoom level changes

Medium Severity · Logic Bug

When zoom or canvasSize changes (e.g., user scrolls to zoom), the keyboard panning useEffect re-runs due to these values being in its dependency array. The cleanup function calls pressed.clear() on keysPressedRef.current, which erases all currently-tracked key presses. If a user is holding a pan key (like 'W') while zooming with the scroll wheel, panning stops abruptly and they must release and re-press the key to continue.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have manually tested this and cannot reproduce the behavior mentioned by Bugbot during gameplay. It acts as it is supposed to enabling zooming and moving at the same time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated keyboard panning logic across two components

Medium Severity · Code Quality

The keyboard panning useEffect (~75 lines) is a near-identical copy of the existing implementation in CanvasIsometricGrid.tsx (lines 604-678). Both contain the same isTypingTarget helper, identical event handlers, animation frame loop, key mappings, and bounds calculation logic. This logic should be extracted into a shared custom hook like useKeyboardPanning.

Fix in Cursor Fix in Web


// Navigate to target
useEffect(() => {
Expand Down