From e4f9747d225b6b32591fb661306854f576ab8523 Mon Sep 17 00:00:00 2001 From: Jeremy Demers <52353432+JeremyDemers@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:57:22 -0400 Subject: [PATCH] Add Tetris touch gestures --- tetris/frontend/app/globals.css | 34 ++++++ tetris/frontend/components/TetrisGame.tsx | 129 +++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/tetris/frontend/app/globals.css b/tetris/frontend/app/globals.css index 51f45bc..db6b18b 100644 --- a/tetris/frontend/app/globals.css +++ b/tetris/frontend/app/globals.css @@ -599,6 +599,26 @@ h1 { .key-hints span { display: inline-flex; align-items: center; gap: 4px; } +.touch-hints { + display: none; + flex-wrap: wrap; + gap: 8px 13px; + justify-content: center; + padding: 12px 4px 0; + color: var(--muted); + font-size: 0.65rem; + font-weight: 800; +} + +.touch-hints span { display: inline-flex; align-items: center; gap: 5px; } + +.touch-hints strong { + color: var(--cyan); + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + kbd { display: inline-grid; min-width: 22px; @@ -828,6 +848,20 @@ kbd { .key-hints { gap: 7px 10px; } } +@media (hover: none), (pointer: coarse) { + .board.playing { + cursor: grab; + touch-action: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + } + + .board.playing:active { cursor: grabbing; } + .key-hints { display: none; } + .touch-hints { display: flex; } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/tetris/frontend/components/TetrisGame.tsx b/tetris/frontend/components/TetrisGame.tsx index fb80e99..a70f5d1 100644 --- a/tetris/frontend/components/TetrisGame.tsx +++ b/tetris/frontend/components/TetrisGame.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; import Image from "next/image"; import { Crown, @@ -90,6 +91,24 @@ const LINE_POINTS = [0, 100, 300, 500, 800]; type SoundName = "move" | "rotate" | "drop" | "lineClear"; +type BoardGesture = { + pointerId: number; + startX: number; + startY: number; + startedAt: number; + axis: "horizontal" | "vertical" | null; + horizontalSteps: number; + verticalSteps: number; + cellWidth: number; + cellHeight: number; +}; + +const GESTURE_START_THRESHOLD = 10; +const TAP_DISTANCE_THRESHOLD = 12; +const TAP_DURATION_THRESHOLD = 500; +const DRAG_STEP_RATIO = 0.72; +const HARD_DROP_ROW_THRESHOLD = 3; + function emptyBoard(): Board { return Array.from({ length: HEIGHT }, () => Array(WIDTH).fill(0)); } @@ -235,6 +254,7 @@ export function TetrisGame() { const previousLinesRef = useRef(0); const soundsRef = useRef | null>(null); const themeRef = useRef(null); + const boardGestureRef = useRef(null); const playSound = useCallback( (name: SoundName) => { @@ -429,6 +449,16 @@ export function TetrisGame() { setGame((current) => (current.status === "playing" ? moveDown(current) : current)); }, []); + const dragDown = useCallback(() => { + setGame((current) => { + if (current.status !== "playing") return current; + const active = { ...current.active, y: current.active.y + 1 }; + return collides(current.board, active) + ? current + : { ...current, active, score: current.score + 1 }; + }); + }, []); + const hardDrop = useCallback(() => { setGame((current) => { if (current.status !== "playing") return current; @@ -502,6 +532,91 @@ export function TetrisGame() { return () => window.removeEventListener("keydown", onKeyDown); }, [hardDrop, hold, move, playSound, rotateActive, softDrop, togglePause]); + const beginBoardGesture = useCallback((event: ReactPointerEvent) => { + if (game.status !== "playing" || event.pointerType === "mouse" || !event.isPrimary) return; + + event.preventDefault(); + const bounds = event.currentTarget.getBoundingClientRect(); + event.currentTarget.setPointerCapture(event.pointerId); + boardGestureRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startedAt: performance.now(), + axis: null, + horizontalSteps: 0, + verticalSteps: 0, + cellWidth: bounds.width / WIDTH, + cellHeight: bounds.height / HEIGHT, + }; + }, [game.status]); + + const continueBoardGesture = useCallback((event: ReactPointerEvent) => { + const gesture = boardGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + + event.preventDefault(); + const deltaX = event.clientX - gesture.startX; + const deltaY = event.clientY - gesture.startY; + const distanceX = Math.abs(deltaX); + const distanceY = Math.abs(deltaY); + + if (!gesture.axis) { + if (Math.max(distanceX, distanceY) < GESTURE_START_THRESHOLD) return; + if (distanceX > distanceY * 1.15) gesture.axis = "horizontal"; + else if (deltaY > 0 && distanceY > distanceX * 1.15) gesture.axis = "vertical"; + else return; + } + + if (gesture.axis === "horizontal") { + const targetSteps = Math.trunc(deltaX / (gesture.cellWidth * DRAG_STEP_RATIO)); + const stepDelta = targetSteps - gesture.horizontalSteps; + if (!stepDelta) return; + + const direction = Math.sign(stepDelta); + for (let step = 0; step < Math.abs(stepDelta); step += 1) move(direction); + gesture.horizontalSteps = targetSteps; + playSound("move"); + return; + } + + const targetSteps = Math.max(0, Math.trunc(deltaY / (gesture.cellHeight * DRAG_STEP_RATIO))); + const stepDelta = targetSteps - gesture.verticalSteps; + if (stepDelta <= 0) return; + + for (let step = 0; step < stepDelta; step += 1) dragDown(); + gesture.verticalSteps = targetSteps; + playSound("drop"); + }, [dragDown, move, playSound]); + + const endBoardGesture = useCallback((event: ReactPointerEvent, cancelled = false) => { + const gesture = boardGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + boardGestureRef.current = null; + if (cancelled) return; + + event.preventDefault(); + const deltaX = event.clientX - gesture.startX; + const deltaY = event.clientY - gesture.startY; + const distance = Math.hypot(deltaX, deltaY); + const duration = performance.now() - gesture.startedAt; + + if (!gesture.axis && distance <= TAP_DISTANCE_THRESHOLD && duration <= TAP_DURATION_THRESHOLD) { + rotateActive(); + playSound("rotate"); + return; + } + + if (gesture.axis === "vertical" && gesture.verticalSteps >= HARD_DROP_ROW_THRESHOLD) { + hardDrop(); + playSound("drop"); + } + }, [hardDrop, playSound, rotateActive]); + function clearLocalSession() { window.localStorage.removeItem(STORAGE_KEY); window.google?.accounts.id.disableAutoSelect(); @@ -614,7 +729,14 @@ export function TetrisGame() { Level {String(game.level).padStart(2, "0")}
-
+
endBoardGesture(event, true)} + aria-describedby="touch-control-hints" + > {game.board.flatMap((row, y) => row.map((value, x) => { const activeCell = visible.get(`${x}:${y}`); @@ -641,6 +763,11 @@ export function TetrisGame() { Space Drop C Hold
+
+ Tap Rotate + Drag Move + Swipe ↓ Drop +