Improve Pong mode selection and ball physics

This commit is contained in:
2026-07-27 11:30:24 +09:00
parent 604080749b
commit 5b8c0048b5
2 changed files with 64 additions and 5 deletions
+37 -5
View File
@@ -19,9 +19,22 @@ export class PongGame {
this.move(side, action); this.move(side, action);
if (this.bot && this.random() > 0.25) this.rightY = Math.max(0, Math.min(this.height - 3, this.rightY + Math.sign(this.ballY - (this.rightY + 1)))); if (this.bot && this.random() > 0.25) this.rightY = Math.max(0, Math.min(this.height - 3, this.rightY + Math.sign(this.ballY - (this.rightY + 1))));
let nextX = this.ballX + this.vx; let nextY = this.ballY + this.vy; let nextX = this.ballX + this.vx; let nextY = this.ballY + this.vy;
if (nextY < 0 || nextY >= this.height) { this.vy *= -1; nextY = this.ballY + this.vy; } if (nextY < 0 || nextY >= this.height) {
if (this.vx < 0 && nextX <= 1 && nextY >= this.leftY && nextY <= this.leftY + 2) { this.vx = 1; nextX = 2; } this.vy *= -1;
if (this.vx > 0 && nextX >= this.width - 2 && nextY >= this.rightY && nextY <= this.rightY + 2) { this.vx = -1; nextX = this.width - 3; } nextY = Math.max(0, Math.min(this.height - 1, this.ballY + this.vy));
}
const bounce = (paddleY, direction, x) => {
const hit = nextY - (paddleY + 1);
if (Math.abs(hit) > 1) return false;
// Flat center returns are harder to exploit, so accelerate them slightly.
this.vx = direction * (Math.abs(hit) < 0.1 ? 1.25 : 1);
// The contact point controls the angle: corners produce the steepest deflection.
this.vy = hit * 1.2;
nextX = x;
return true;
};
if (this.vx < 0 && nextX <= 1) bounce(this.leftY, 1, 2);
if (this.vx > 0 && nextX >= this.width - 2) bounce(this.rightY, -1, this.width - 3);
this.ballX = nextX; this.ballY = nextY; this.ballX = nextX; this.ballY = nextY;
if (this.ballX < 0) { this.rightScore++; this.serve(1); } if (this.ballX < 0) { this.rightScore++; this.serve(1); }
if (this.ballX >= this.width) { this.leftScore++; this.serve(-1); } if (this.ballX >= this.width) { this.leftScore++; this.serve(-1); }
@@ -31,7 +44,8 @@ export class PongGame {
render(side = 'left') { render(side = 'left') {
const cells = grid(this.width, this.height); const cells = grid(this.width, this.height);
for (let i = 0; i < 3; i++) { cells[this.leftY + i][1] = '|'; cells[this.rightY + i][this.width - 2] = '|'; } for (let i = 0; i < 3; i++) { cells[this.leftY + i][1] = '|'; cells[this.rightY + i][this.width - 2] = '|'; }
if (this.ballY >= 0 && this.ballY < this.height && this.ballX >= 0 && this.ballX < this.width) cells[this.ballY][this.ballX] = 'o'; const renderX = Math.round(this.ballX); const renderY = Math.round(this.ballY);
if (renderY >= 0 && renderY < this.height && renderX >= 0 && renderX < this.width) cells[renderY][renderX] = 'o';
const label = side === 'left' ? `${this.leftScore} - ${this.rightScore}` : `${this.rightScore} - ${this.leftScore}`; const label = side === 'left' ? `${this.leftScore} - ${this.rightScore}` : `${this.rightScore} - ${this.leftScore}`;
return `${frame(cells)}\nYou - Opponent: ${label}${this.over ? ' MATCH OVER' : ''}`; return `${frame(cells)}\nYou - Opponent: ${label}${this.over ? ' MATCH OVER' : ''}`;
} }
@@ -45,6 +59,24 @@ export class PongService {
this.live.delete(ctx); ctx.tui?.setInputHandler(null); this.live.delete(ctx); ctx.tui?.setInputHandler(null);
if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`); if (message) ctx.tui?.finishLive(`\x1b[?25h${message}`);
} }
chooseMode(ctx) {
if (!ctx.tui) return 'Pong is interactive; connect with an SSH shell (without a remote command).';
let selected = 0;
const modes = [['BOT', 'Play against the computer'], ['ONLINE', 'Play against another connected visitor']];
const render = () => this.draw(ctx, `PONG\n\n${modes.map(([name, description], index) => `${index === selected ? '>' : ' '} ${name.padEnd(7)} ${description}`).join('\n')}`, 'UP/DOWN arrows: choose ENTER: select Q: cancel');
ctx.tui.setInputHandler(input => {
if (input.includes('\x03') || /q/i.test(input)) { this.stop(ctx, '\x1b[?25hPong cancelled.'); return true; }
if (input.includes('\x1b[A') || input.includes('\x1bOA')) selected = (selected + modes.length - 1) % modes.length;
if (input.includes('\x1b[B') || input.includes('\x1bOB')) selected = (selected + 1) % modes.length;
if (input.includes('\r') || input.includes('\n')) {
const result = this.start(ctx, modes[selected][0].toLowerCase());
if (result !== '__LIVE__') ctx.tui?.finishLive(`\x1b[?25h${result}`);
return true;
}
render(); return true;
});
render(); return '__LIVE__';
}
start(ctx, mode) { start(ctx, mode) {
if (!['bot', 'online'].includes(mode)) return 'Usage: pong [bot|online]'; if (!['bot', 'online'].includes(mode)) return 'Usage: pong [bot|online]';
if (!ctx.tui) return 'Pong is interactive; connect with an SSH shell (without a remote command).'; if (!ctx.tui) return 'Pong is interactive; connect with an SSH shell (without a remote command).';
@@ -84,4 +116,4 @@ export class PongService {
} }
} }
export function run({ arg, ctx, api }) { return api.pong.start(ctx, arg.toLowerCase() || 'bot'); } export function run({ arg, ctx, api }) { return arg.trim() ? api.pong.start(ctx, arg.toLowerCase()) : api.pong.chooseMode(ctx); }
+27
View File
@@ -26,6 +26,21 @@ test('pong bot can miss when its reaction is delayed', () => {
assert.equal(game.rightY, 6); assert.equal(game.rightY, 6);
}); });
test('pong paddle contact changes trajectory by hit position', () => {
const game = new PongGame(false);
game.leftY = 4; game.ballX = 2; game.ballY = 4; game.vx = -1; game.vy = 0;
game.tick('left', 'stay');
assert.equal(game.vx, 1);
assert.ok(game.vy < 0, 'upper-edge contact should send the ball upward');
});
test('pong flat returns accelerate slightly', () => {
const game = new PongGame(false);
game.leftY = 4; game.ballX = 2; game.ballY = 5; game.vx = -1; game.vy = 0;
game.tick('left', 'stay');
assert.equal(game.vx, 1.25);
});
test('live flappy consumes Space and exits on q', () => { test('live flappy consumes Space and exits on q', () => {
let handler; let finished = ''; let writes = 0; let handler; let finished = ''; let writes = 0;
const service = new FlappyService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } }); const service = new FlappyService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } });
@@ -66,3 +81,15 @@ test('live pong consumes arrow keys and exits on q', () => {
handler('\x1b[A'); handler('\x1b[B'); handler('q'); handler('\x1b[A'); handler('\x1b[B'); handler('q');
assert.match(finished, /left/); assert.match(finished, /left/);
}); });
test('pong mode picker selects online with arrow keys and enter', () => {
let handler; let finished = ''; let screen = '';
const service = new PongService({ addScore() {}, getLeaderboard() { return []; }, getPlayerBest() { return 0; } });
const ctx = { username: 'visitor', channel: { destroyed: false, write(value) { screen = value; } }, tui: {
setInputHandler(value) { handler = value; }, finishLive(value) { finished = value; }
} };
assert.equal(service.chooseMode(ctx), '__LIVE__');
assert.match(screen, /BOT/); assert.match(screen, /ONLINE/);
handler('\x1b[B'); handler('\r');
assert.match(finished, /Waiting for another player/);
});