diff --git a/docs/audio-design.md b/docs/audio-design.md index 5b3cdbe..2aaa04b 100644 --- a/docs/audio-design.md +++ b/docs/audio-design.md @@ -60,6 +60,12 @@ filtered-noise offbeats. `intensity` increases melodic detail and may increase tempo by at most 10 BPM; `danger` introduces a restrained counter-line. Neither value rewrites game timing. +For Tetris, the locked stack height is published as normalized danger. Once the +stack exceeds two-thirds of the 20-row playfield, the scheduler adds 32 BPM to +the current level-based tempo; clearing back to two-thirds or lower removes the +boost. The next scheduler window adopts either transition without restarting +the melody. + Theme profiles change oscillator types, brightness, envelope shape, density, and gain. The active composition remains recognizable across themes. Theme changes affect newly scheduled voices and do not require controllers to know theme names. diff --git a/docs/audio.md b/docs/audio.md index 39aca6e..e2dfae7 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -20,6 +20,10 @@ timbre: Music reacts lightly to progress, pace, or danger. It does not affect mechanics, online state, results, or scoring. +In Tetris, the music shifts into a faster tension tempo when the locked tower +rises above two-thirds of the playfield, then returns to its normal tempo after +line clears or power-ups bring the tower back down. + ## Public-domain quotations The following note sequences are manually encoded in `scripts/audio.js`. diff --git a/releases.json b/releases.json index e82fffd..1982276 100644 --- a/releases.json +++ b/releases.json @@ -6,7 +6,8 @@ "title": "JavaScript Playground 1.2.0", "summary": "Achievements now celebrate key Tetris and Battle Tanks milestones at the moment players earn them.", "highlights": [ - "See achievement unlocks during active play when reaching Tetris level 10, clearing four lines at once, or completing eligible Battle Tanks power-up and tactical challenges." + "See achievement unlocks during active play when reaching Tetris level 10, clearing four lines at once, or completing eligible Battle Tanks power-up and tactical challenges.", + "Hear the Tetris music accelerate when the tower rises above two-thirds of the playfield, then settle back to its normal tempo when the danger clears." ], "fixes": [ "Prevents live achievement notifications from appearing again when the final game result is recorded.", diff --git a/scripts/audio.js b/scripts/audio.js index e2e140d..1824a36 100644 --- a/scripts/audio.js +++ b/scripts/audio.js @@ -1,7 +1,15 @@ 'use strict'; +const TETRIS_DANGER_THRESHOLD = 2 / 3; +const TETRIS_DANGER_BPM_BOOST = 32; +function musicBpm(game, track, detail = {}) { + const intensity = Math.max(0, Math.min(1, Number(detail.intensity ?? .35))); + const danger = Math.max(0, Math.min(1, Number(detail.danger ?? 0))); + return track.bpm + intensity * 10 + (game === 'tetris' && danger > TETRIS_DANGER_THRESHOLD ? TETRIS_DANGER_BPM_BOOST : 0); +} + (function attachArcadeAudio(root, factory) { - if (typeof module === 'object' && module.exports) module.exports = { createArcadeAudio: factory }; + if (typeof module === 'object' && module.exports) module.exports = { createArcadeAudio: factory, musicBpm }; if (root?.document) root.ArcadeAudio = factory(root); })(typeof window === 'undefined' ? null : window, function createArcadeAudio(root) { const doc = root.document; @@ -192,7 +200,7 @@ }; const scheduleMusic = () => { if (!musicAllowed()) return; - const track = TRACKS[game], intensity = clamp(sceneDetail.intensity ?? .35), beat = 60 / (track.bpm + intensity * 10) / 2; + const track = TRACKS[game], beat = 60 / musicBpm(game, track, sceneDetail) / 2; if (nextStepTime < context.currentTime - .2) nextStepTime = context.currentTime + .04; while (nextStepTime < context.currentTime + .16) { scheduleStep(stepIndex, nextStepTime + (stepIndex % 2 ? beat * track.swing : 0), beat); stepIndex += 1; nextStepTime += beat; } }; diff --git a/tests/audio-system.test.js b/tests/audio-system.test.js index 7c18cbe..0e66a07 100644 --- a/tests/audio-system.test.js +++ b/tests/audio-system.test.js @@ -4,7 +4,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createArcadeAudio } = require('../scripts/audio.js'); +const { createArcadeAudio, musicBpm } = require('../scripts/audio.js'); const { createArcadeEvents } = require('../scripts/game-events.js'); const root = path.resolve(__dirname, '..'); @@ -106,6 +106,14 @@ test('the Tetris track preserves the Korobeiniki phrase at starting intensity in assert.deepEqual(melody.slice(0, expected.length).map(value => Math.round(value * 1000)), expected.map(value => Math.round(value * 1000))); }); +test('Tetris tempo accelerates above two-thirds tower height and returns below it', () => { + const track = { bpm: 132 }, detail = { intensity: .4 }; + assert.equal(musicBpm('tetris', track, { ...detail, danger: .65 }), 136); + assert.equal(musicBpm('tetris', track, { ...detail, danger: .7 }), 168); + assert.equal(musicBpm('tetris', track, { ...detail, danger: .4 }), 136); + assert.equal(musicBpm('pong', track, { ...detail, danger: .9 }), 136, 'the threshold boost is Tetris-only'); +}); + test('melodies advance an octave for every complete scale traversal', async () => { FakeAudioContext.instances.length = 0; const { env, intervals } = environment({ pathname: '/Sudoku/' }); diff --git a/tests/tetris.test.js b/tests/tetris.test.js index 98bc862..bbd3668 100644 --- a/tests/tetris.test.js +++ b/tests/tetris.test.js @@ -5,7 +5,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { TetrisGame, TYPES, MAGIC_BLOCK_POINTS } = require('../tetris/scripts/game'); +const { TetrisGame, TYPES, MAGIC_BLOCK_POINTS, stackHeightRatio } = require('../tetris/scripts/game'); const { validateResult, Accounts } = require('../server/accounts'); const { Achievements } = require('../server/achievements'); const { openDatabase } = require('../server/database'); @@ -18,6 +18,28 @@ test('seven-bag generation contains every tetromino exactly once', () => { assert.deepEqual(new Set([game.piece.type, ...game.queue.slice(0, 6)]), new Set(TYPES)); }); +test('stack height reports crossings above and below two-thirds of the visible board', () => { + const board = Array.from({ length: 20 }, () => Array(10).fill(null)); + assert.equal(stackHeightRatio(board), 0); + board[7][0] = 'T'; + assert.equal(stackHeightRatio(board), .65); + board[6][0] = 'T'; + assert.equal(stackHeightRatio(board), .7); + board[6][0] = null; board[7][0] = null; board[12][0] = 'T'; + assert.equal(stackHeightRatio(board), .4); +}); + +test('Tetris controller preserves tower danger when an older mechanics script is cached', () => { + const app = read('tetris/scripts/app.js'); + const source = app.match(/function towerHeightRatio[\s\S]*?\n }/)?.[0]; + assert.ok(source, 'controller should define a compatibility helper'); + const towerHeightRatio = Function(`${source}; return towerHeightRatio;`)(); + const board = Array.from({ length: 20 }, () => Array(10).fill(null)); + board[6][0] = 'T'; + assert.equal(towerHeightRatio(board, {}), .7, 'older cached mechanics should use the controller fallback'); + assert.equal(towerHeightRatio(board, { stackHeightRatio: () => .4 }), .4, 'current mechanics should remain authoritative'); +}); + test('movement, rotation, hold, ghost, and hard drop obey game boundaries', () => { const game = new TetrisGame({ random: () => .2 }); while (game.move(-1)); diff --git a/tetris/scripts/app.js b/tetris/scripts/app.js index ec7bf29..6ccf4c5 100644 --- a/tetris/scripts/app.js +++ b/tetris/scripts/app.js @@ -90,8 +90,14 @@ events.emit('tetris:local-record-broken', { score: game.score, previous: standingBest }); clearTimeout(recordTimer); recordTimer = setTimeout(() => { stageElement.classList.remove('is-new-record'); recordCalloutElement.classList.remove('is-active'); }, 2200); } + function towerHeightRatio(board, rules = window.TetrisRules) { + if (typeof rules.stackHeightRatio === 'function') return rules.stackHeightRatio(board); + if (!Array.isArray(board) || !board.length) return 0; + const highest = board.findIndex(row => Array.isArray(row) && row.some(Boolean)); + return highest < 0 ? 0 : (board.length - highest) / board.length; + } function progressDetail(board = game.visibleBoard()) { - const highest = board.findIndex(row => row.some(Boolean)), danger = highest < 0 ? 0 : Math.max(0, Math.min(1, (8 - highest) / 8)); + const danger = towerHeightRatio(board); return { level: game.level, intensity: Math.min(.9, .2 + game.level * .07), danger }; } function checkpointLiveAchievements() { diff --git a/tetris/scripts/game.js b/tetris/scripts/game.js index 65a6b81..106d15d 100644 --- a/tetris/scripts/game.js +++ b/tetris/scripts/game.js @@ -33,6 +33,11 @@ }); const emptyBoard = () => Array.from({ length: HEIGHT }, () => Array(WIDTH).fill(null)); + function stackHeightRatio(board) { + if (!Array.isArray(board) || !board.length) return 0; + const highest = board.findIndex(row => Array.isArray(row) && row.some(Boolean)); + return highest < 0 ? 0 : (board.length - highest) / board.length; + } const cellsFor = piece => piece.type === 'M' ? [[piece.x, piece.y]] : SHAPES[piece.type][piece.rotation].map(([x, y]) => [piece.x + x, piece.y + y]); class TetrisGame { @@ -204,5 +209,5 @@ details(seconds) { return { mode: 'marathon', seconds, lines: this.lines, level: this.level, pieces: this.pieces, singles: this.singles, doubles: this.doubles, triples: this.triples, tetrises: this.tetrises, softDropCells: this.softDropCells, hardDropCells: this.hardDropCells, magicPowerUps: this.magicPowerUps, magicBlocksDestroyed: this.magicBlocksDestroyed, shakePowerUps: this.shakePowerUps }; } } - return { TetrisGame, WIDTH, HEIGHT, HIDDEN_ROWS, VISIBLE_ROWS, TYPES, SHAPES, MAGIC_BLOCK_POINTS, cellsFor }; + return { TetrisGame, WIDTH, HEIGHT, HIDDEN_ROWS, VISIBLE_ROWS, TYPES, SHAPES, MAGIC_BLOCK_POINTS, cellsFor, stackHeightRatio }; });