From 05c7d636d823e1a61fef5f716986cbadd53a2564 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 11 Jul 2026 09:51:21 +0200 Subject: [PATCH] Portrait mode and one-handed touch controls (drag/flick/tap) Co-Authored-By: Claude Fable 5 --- game.js | 71 ++++++++++++++++++++++++++++++++++++++++++------------ index.html | 1 + 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/game.js b/game.js index e265d8e..5fbcc79 100644 --- a/game.js +++ b/game.js @@ -18,6 +18,7 @@ const TUNE = { aimError: 2.4, // m of scatter at quality 0 perfectMs: 45, // |offset from window center| for PERFECT aiQualityMin: 0.5, aiQualityMax: 0.95, aiFlub: 0.06, + autoAssist: matchMedia('(pointer: coarse)').matches ? 0.65 : 0, // auto-run toward ball x (mobile) }; const COURT = { L: 23.77, W: 8.23, netZ: 11.885, netH: 0.914, netW: 10.06, serviceD: 6.4 }; @@ -27,8 +28,22 @@ COURT.svcNear = COURT.netZ + 6.4; // 18.285 // ---------------------------------------------------------------- canvas ---- const cv = document.getElementById('c'), ctx = cv.getContext('2d'); -const W = cv.width, H = cv.height; -ctx.imageSmoothingEnabled = false; +let W = cv.width, H = cv.height; + +function fitViewport() { // landscape 480x270, portrait 270x480 with a longer lens + const portrait = innerHeight > innerWidth; + const [w, h] = portrait ? [270, 480] : [480, 270]; + if (cv.width !== w) { + cv.width = W = w; cv.height = H = h; + TUNE.camF = portrait ? 400 : 280; + TUNE.horizon = portrait ? 90 : 40; + buildCrowd(); + } + ctx.imageSmoothingEnabled = false; // resets on canvas resize + const sc = Math.min(innerWidth / w, innerHeight / h); + cv.style.width = w * sc + 'px'; cv.style.height = h * sc + 'px'; +} +addEventListener('resize', fitViewport); function proj(x, y, z) { const d = TUNE.camZ - z, s = TUNE.camF / d; @@ -48,15 +63,16 @@ const touchAxis = { x: 0, z: 0 }; const axisX = () => touchAxis.x || (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0); const axisZ = () => touchAxis.z || (keys.ArrowUp || keys.KeyW ? -1 : 0) + (keys.ArrowDown || keys.KeyS ? 1 : 0); -// touch: left/first finger drags to move (also sets aim), any extra finger tap = swing/serve. -// A quick tap of the first finger swings too, so one-handed play works. -let stick = null; // {id, x0, y0, t0, moved} +// One-handed touch: hold-drag = run, quick FLICK = swing aimed in the flick direction, +// plain tap = neutral swing / toss / serve. Extra finger tap still swings (two-thumb play). +let stick = null; // {id, x0, y0, t0, moved} +let flickAim = null; // {ax, az, t} aim override for the swing just triggered const DEAD = 14; cv.addEventListener('touchstart', e => { e.preventDefault(); audio(); for (const t of e.changedTouches) { if (!stick) stick = { id: t.identifier, x0: t.clientX, y0: t.clientY, t0: performance.now(), moved: false }; - else keyPressed('Space'); // second finger = swing + else keyPressed('Space'); } }, { passive: false }); cv.addEventListener('touchmove', e => { @@ -74,7 +90,14 @@ const endTouch = e => { e.preventDefault(); for (const t of e.changedTouches) { if (!stick || t.identifier !== stick.id) continue; - if (!stick.moved && performance.now() - stick.t0 < 250) keyPressed('Space'); // tap = swing + const dt = performance.now() - stick.t0; + const dx = t.clientX - stick.x0, dy = t.clientY - stick.y0, dist = Math.hypot(dx, dy); + if (!stick.moved && dt < 250) keyPressed('Space'); // tap + else if (dt < 300 && dist > 28) { // flick = aimed swing + flickAim = { ax: Math.abs(dx) > dist * 0.38 ? Math.sign(dx) : 0, + az: Math.abs(dy) > dist * 0.38 ? Math.sign(dy) : 0, t: performance.now() }; + keyPressed('Space'); + } stick = null; touchAxis.x = 0; touchAxis.z = 0; } }; @@ -150,10 +173,15 @@ let phase = 'pretoss'; // pretoss | toss | rally | over let serveNum = 1, overT = 0, hitstop = 0, flash = null; // flash = {text,x,y,t} let aiServeT = 0, aiSwingCool = 0; const crowd = { cheer: 0, dots: [] }; -for (let row = 0; row < 16; row++) for (let i = 0; i < 60; i++) crowd.dots.push({ - x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2, - c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8] -}); +function buildCrowd() { + crowd.dots.length = 0; + const rows = Math.floor((proj(0, 0, 0).y - 26) / 5.5), cols = Math.ceil(W / 8); + for (let row = 0; row < rows; row++) for (let i = 0; i < cols; i++) crowd.dots.push({ + x: 4 + i * 8 + (row % 2) * 4 + Math.random() * 3, y: 8 + row * 5.5 + Math.random() * 2, + c: ['#c9b18f','#8f6f4f','#d9c6a5','#5f7f9f','#9f5f5f','#7f9f5f','#e0d0b0','#b0a0c0'][(i * 7 + row) % 8] + }); +} +buildCrowd(); const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side function setupPoint() { @@ -293,9 +321,12 @@ function trySwing(ch, isAI) { let q = Math.max(0.05, 1 - off); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite + const fresh = flickAim && performance.now() - flickAim.t < 450; const aim = isAI ? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 } + : fresh ? { ax: flickAim.ax, az: flickAim.az } : { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) }; + if (fresh && !isAI) flickAim = null; if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin)); doHit(ch, q, aim); ch.swingT = -1; @@ -330,6 +361,10 @@ function update(dt) { if (phase === 'rally') { P.x += mx * TUNE.playerSpeed * slow * dt; P.z += mz * TUNE.playerSpeed * slow * dt; + if (!mx && !mz && TUNE.autoAssist > 0 && ball.active && ball.lastHit === 'O') { + const d = ball.x - P.x; // drift toward the incoming ball so one thumb suffices + P.x += Math.sign(d) * Math.min(Math.abs(d), TUNE.playerSpeed * TUNE.autoAssist * slow * dt); + } P.x = Math.max(-6.5, Math.min(6.5, P.x)); P.z = Math.max(COURT.netZ + 1.2, Math.min(26, P.z)); P.runT += (Math.abs(mx) + Math.abs(mz)) * dt; @@ -471,8 +506,9 @@ function render() { } // courtside billboard wall ctx.fillStyle = '#0e5c2e'; ctx.fillRect(0, standsBottom - 10, W, 10); - ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; - ctx.fillText('· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·', W / 2 - 128, standsBottom - 9); + ctx.fillStyle = '#dfe8d0'; ctx.font = 'bold 7px monospace'; ctx.textBaseline = 'top'; ctx.textAlign = 'center'; + ctx.fillText(W > 400 ? '· SPLATTER OPEN · NEW BALLS PLEASE · SPLATTER OPEN ·' : '· SPLATTER OPEN ·', W / 2, standsBottom - 9); + ctx.textAlign = 'left'; // ground + court (Super Tennis lawn) quadXZ(-14, -6, 14, 30, '#2f7a35'); // surround @@ -522,14 +558,18 @@ function render() { ctx.fillText('YOU ' + score.games[0] + ' ' + ptTxt(0), 12, 10); ctx.fillStyle = score.server === 'O' ? '#f8e048' : '#c8d0dc'; ctx.fillText('CPU ' + score.games[1] + ' ' + ptTxt(1), 12, 22); + ctx.textAlign = 'center'; if (phase === 'pretoss' && score.server === 'P') { ctx.fillStyle = '#f8e048'; ctx.font = 'bold 8px monospace'; - ctx.fillText(serveNum === 2 ? 'SECOND SERVE — SPACE' : 'SPACE TO TOSS, SPACE TO SERVE', W / 2 - 80, H - 16); + const touch = matchMedia('(pointer: coarse)').matches; + ctx.fillText(serveNum === 2 ? (touch ? 'SECOND SERVE — TAP' : 'SECOND SERVE — SPACE') + : (touch ? 'TAP TO TOSS, TAP TO SERVE' : 'SPACE TO TOSS, SPACE TO SERVE'), W / 2, H - 16); } if (flash) { ctx.font = 'bold 14px monospace'; ctx.fillStyle = '#ffffff'; - ctx.fillText(flash.text, W / 2 - 30, 60 + Math.sin(flash.t * 20) * 1); + ctx.fillText(flash.text, W / 2, 60 + Math.sin(flash.t * 20) * 1); } + ctx.textAlign = 'left'; } // ------------------------------------------------------------- debug panel ---- @@ -555,6 +595,7 @@ function frame(now) { update(dt); render(); requestAnimationFrame(frame); } +fitViewport(); setupPoint(); say('Quiet please. Ready? Play.'); requestAnimationFrame(frame); diff --git a/index.html b/index.html index 1d4a5a2..9454af8 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ canvas { image-rendering: pixelated; width: min(96vw, calc(96vh * 16 / 9)); aspect-ratio: 16/9; touch-action: none; } #hint { position: fixed; bottom: 8px; width: 100%; text-align: center; font: 12px monospace; color: #6b7688; } + @media (pointer: coarse) { #hint { display: none; } }