Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/audio-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
3 changes: 2 additions & 1 deletion releases.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
12 changes: 10 additions & 2 deletions scripts/audio.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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; }
};
Expand Down
10 changes: 9 additions & 1 deletion tests/audio-system.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');
Expand Down Expand Up @@ -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/' });
Expand Down
24 changes: 23 additions & 1 deletion tests/tetris.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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));
Expand Down
8 changes: 7 additions & 1 deletion tetris/scripts/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
7 changes: 6 additions & 1 deletion tetris/scripts/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
});