Portrait mode and one-handed touch controls (drag/flick/tap)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-07-11 09:51:21 +02:00
1 parent c2ba256b3b
commit 05c7d636d8
2 files changed
+57 -15

No files matched your search

+56 -15
View File
@@ -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);
+1
View File
@@ -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; } }
</style>
</head>
<body>