Portrait mode and one-handed touch controls (drag/flick/tap)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2ba256b3b
commit
05c7d636d8
2 files changed
+54
-12
No files matched your search
@@ -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.
|
||||
// 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({
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user