diff --git a/src/commands/pong.js b/src/commands/pong.js index ebd61ba..b882623 100644 --- a/src/commands/pong.js +++ b/src/commands/pong.js @@ -19,9 +19,22 @@ export class PongGame { 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)))); 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 (this.vx < 0 && nextX <= 1 && nextY >= this.leftY && nextY <= this.leftY + 2) { this.vx = 1; nextX = 2; } - if (this.vx > 0 && nextX >= this.width - 2 && nextY >= this.rightY && nextY <= this.rightY + 2) { this.vx = -1; nextX = this.width - 3; } + if (nextY < 0 || nextY >= this.height) { + this.vy *= -1; + 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; if (this.ballX < 0) { this.rightScore++; this.serve(1); } if (this.ballX >= this.width) { this.leftScore++; this.serve(-1); } @@ -31,7 +44,8 @@ export class PongGame { render(side = 'left') { 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] = '|'; } - 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}`; 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); 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) { 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).'; @@ -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); } diff --git a/test/games.test.js b/test/games.test.js index 4fba865..ef613ee 100644 --- a/test/games.test.js +++ b/test/games.test.js @@ -26,6 +26,21 @@ test('pong bot can miss when its reaction is delayed', () => { 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', () => { let handler; let finished = ''; let writes = 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'); 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/); +});