Add spatial audio and sound effect captions

This commit is contained in:
Eevee (Evelyn Woods) 2021-12-22 20:54:44 -07:00
parent 91a5ab6786
commit c8de4edfff
5 changed files with 280 additions and 22 deletions

View File

@ -252,6 +252,7 @@
<section id="player-game-area"> <section id="player-game-area">
<div class="level"><!-- level canvas and any overlays go here --></div> <div class="level"><!-- level canvas and any overlays go here --></div>
<div class="player-overlay-message"></div> <div class="player-overlay-message"></div>
<div class="player-overlay-captions"></div>
<div class="player-hint-wrapper"> <div class="player-hint-wrapper">
<div class="player-hint"></div> <div class="player-hint"></div>
<svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg> <svg class="player-hint-bg-icon svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-hint"></use></svg>

View File

@ -887,7 +887,6 @@ export class Level extends LevelInterface {
} }
} }
this.sfx.set_player_position(this.player.cell);
this.pending_green_toggle = false; this.pending_green_toggle = false;
} }
@ -1078,7 +1077,7 @@ export class Level extends LevelInterface {
// This is only used for checking when to play the mmf sound, doesn't need undoing; // This is only used for checking when to play the mmf sound, doesn't need undoing;
// it's cleared when we make a successful move or a null decision // it's cleared when we make a successful move or a null decision
actor.last_blocked_direction = actor.direction; actor.last_blocked_direction = actor.direction;
this.sfx.play_once('blocked'); this.sfx.play_once('blocked', actor.cell);
} }
this._set_tile_prop(actor, 'is_blocked', true); this._set_tile_prop(actor, 'is_blocked', true);
} }

View File

@ -57,7 +57,6 @@ const ANSI_RESET = "\x1b[39m";
async function test_pack(pack, ruleset, level_filter = null) { async function test_pack(pack, ruleset, level_filter = null) {
let dummy_sfx = { let dummy_sfx = {
set_player_position() {},
play() {}, play() {},
play_once() {}, play_once() {},
}; };

View File

@ -246,10 +246,14 @@ const OBITUARIES = {
}; };
// Helper class used to let the game play sounds without knowing too much about the Player // Helper class used to let the game play sounds without knowing too much about the Player
class SFXPlayer { class SFXPlayer {
constructor() { constructor(place_caption_cb) {
this.place_caption_cb = place_caption_cb;
this.ctx = new (window.AudioContext || window.webkitAudioContext); // come the fuck on, safari this.ctx = new (window.AudioContext || window.webkitAudioContext); // come the fuck on, safari
this.volume = 1.0; this.volume = 1.0;
this.enabled = true; this.enabled = true;
// 0 disabled; 1 adjust gain only; 2 full spatial panning
this.spatial_mode = 2;
// This automatically reduces volume when a lot of sound effects are playing at once // This automatically reduces volume when a lot of sound effects are playing at once
this.compressor_node = this.ctx.createDynamicsCompressor(); this.compressor_node = this.ctx.createDynamicsCompressor();
@ -257,6 +261,22 @@ class SFXPlayer {
this.compressor_node.ratio.value = 16; this.compressor_node.ratio.value = 16;
this.compressor_node.connect(this.ctx.destination); this.compressor_node.connect(this.ctx.destination);
// Set up spatial sound. Units are cells. The listener is aligned with the center of the
// viewport (NOT the player), and moved out some distance to put it where the player is.
// The twiddles here (distance from screen, and ref distance + rolloff in play_once()) were
// designed to emulate my homegrown formula as closely as possible, since I did a lot of
// fiddling to come up with that and I like how it came out.
let listener = this.ctx.listener;
listener.positionX.value = 0;
listener.positionY.value = 0;
listener.positionZ.value = -8;
listener.forwardX.value = 0;
listener.forwardY.value = 0;
listener.forwardZ.value = 1;
listener.upX.value = 0;
listener.upY.value = -1;
listener.upZ.value = 0;
this.player_x = null; this.player_x = null;
this.player_y = null; this.player_y = null;
this.sounds = {}; this.sounds = {};
@ -348,6 +368,63 @@ class SFXPlayer {
'revive': 'sfx/revive.ogg', 'revive': 'sfx/revive.ogg',
}; };
this.sound_captions = {
blocked: 'mmf!',
bomb: 'BOOM',
'button-press': 'beep',
'button-release': 'boop',
door: 'ka-chik',
// these are only triggered by the active player
drop: null,
'fake-floor': null,
'get-bonus': null,
'get-bonus2': null,
// these are active player only, but give some audio feedback
'get-chip': 'bwink',
'get-chip-extra': 'bwonk',
'get-chip-last': 'bwenk',
// key and tool play no matter who picks it up (though this is not the case in cc2, so
// arguably wrong; if i ever change that, consider dropping the caption?)
'get-key': 'bwip',
// bonus+penalty can only be collected by player, but toggle can be done by doppelganger
'get-stopwatch-bonus': null,
'get-stopwatch-penalty': null,
'get-stopwatch-toggle': 'bee-beep',
'get-tool': 'bwoop',
// active player only
popwall: null,
push: null,
// can happen offscreen!
socket: 'ka-chunk',
splash: 'splash',
'splash-slime': 'sploosh',
// all steps are active player only
'slide-force': null,
'slide-ice': null,
'step-fire': null,
'step-force': null,
'step-floor': null,
'step-gravel': null,
'step-ice': null,
'step-popdown': null,
'step-water': null,
teleport: 'fwoosh',
// active player only, but useful audio cue
thief: 'dududuh',
'thief-bribe': 'ch-ching',
transmogrify: 'vwoo-wip',
// "Bummer" is pretty classic imo
lose: 'Bummer.',
tick: '...tick...',
timeup: null,
// can happen offscreen
exit: '(exited)',
// flavor, not really useful
win: null,
'revive': null,
};
for (let [name, path] of Object.entries(this.sound_sources)) { for (let [name, path] of Object.entries(this.sound_sources)) {
this.init_sound(name, path); this.init_sound(name, path);
} }
@ -362,9 +439,17 @@ class SFXPlayer {
}; };
} }
set_player_position(cell) { set_listener_position(x, y) {
this.player_x = cell.x; // Note that the given position is the center of a cell, but we play sounds from the top
this.player_y = cell.y; // left corners, so just shave off half a cell here.
x -= 0.5;
y -= 0.5;
this.player_x = x;
this.player_y = y;
let listener = this.ctx.listener;
listener.positionX.value = x;
listener.positionY.value = y;
} }
play_once(name, cell = null) { play_once(name, cell = null) {
@ -384,7 +469,12 @@ class SFXPlayer {
node.buffer = data.audiobuf; node.buffer = data.audiobuf;
let volume = this.volume; let volume = this.volume;
if (cell && this.player_x !== null) { let gain = this.ctx.createGain();
gain.gain.value = volume;
node.connect(gain);
if (cell && this.player_x !== null && this.spatial_mode > 0) {
if (this.spatial_mode === 1) {
// Reduce the volume for further-away sounds // Reduce the volume for further-away sounds
let dx = cell.x - this.player_x; let dx = cell.x - this.player_x;
let dy = cell.y - this.player_y; let dy = cell.y - this.player_y;
@ -394,12 +484,37 @@ class SFXPlayer {
// quickly so you don't get drowned in button spam, but still leaving buttons audible // quickly so you don't get drowned in button spam, but still leaving buttons audible
// even at the far reaches of a 100×100 level. (Maybe because gain is exponential?) // even at the far reaches of a 100×100 level. (Maybe because gain is exponential?)
volume *= 1 - dist / (dist + 2); volume *= 1 - dist / (dist + 2);
}
let gain = this.ctx.createGain();
gain.gain.value = volume; gain.gain.value = volume;
node.connect(gain);
gain.connect(this.compressor_node); gain.connect(this.compressor_node);
}
else if (this.spatial_mode === 2) {
let panner = new PannerNode(this.ctx, {
panningModel: 'HRTF',
distanceModel: 'inverse',
refDistance: 8,
maxDistance: 10000,
rolloffFactor: 4,
positionX: cell.x,
positionY: cell.y,
positionZ: 0,
orientationX: 0,
orientationY: 0,
orientationZ: -1,
});
gain.connect(panner);
panner.connect(this.compressor_node);
}
}
else {
gain.connect(this.compressor_node);
}
node.start(this.ctx.currentTime); node.start(this.ctx.currentTime);
let caption = this.sound_captions[name];
if (caption) {
this.place_caption_cb(cell, caption);
}
} }
} }
class Player extends PrimaryView { class Player extends PrimaryView {
@ -424,9 +539,11 @@ class Player extends PrimaryView {
this.scale = 1; this.scale = 1;
this.play_speed = 1; this.play_speed = 1;
this.show_captions = false;
this.level_el = this.root.querySelector('.level'); this.level_el = this.root.querySelector('.level');
this.overlay_message_el = this.root.querySelector('.player-overlay-message'); this.overlay_message_el = this.root.querySelector('.player-overlay-message');
this.captions_el = this.root.querySelector('.player-overlay-captions');
this.hint_el = this.root.querySelector('.player-hint'); this.hint_el = this.root.querySelector('.player-hint');
this.number_el = this.root.querySelector('.player-level-number output'); this.number_el = this.root.querySelector('.player-level-number output');
this.chips_el = this.root.querySelector('.chips output'); this.chips_el = this.root.querySelector('.chips output');
@ -837,9 +954,16 @@ class Player extends PrimaryView {
this.adjust_scale(); this.adjust_scale();
}); });
// Auto-delete captions once their animations end
this.captions_el.addEventListener('animationend', ev => {
if (ev.target !== this.captions_el) {
this.target.remove();
}
});
// TODO yet another thing that should be in setup, but can't be because load_level is called // TODO yet another thing that should be in setup, but can't be because load_level is called
// first // first
this.sfx_player = new SFXPlayer; this.sfx_player = new SFXPlayer(this.place_caption.bind(this));
} }
setup() { setup() {
@ -1255,6 +1379,10 @@ class Player extends PrimaryView {
if (this.state !== 'waiting') { if (this.state !== 'waiting') {
this.restart_level(); this.restart_level();
} }
// Also nuke all captions, especially since otherwise their animations will restart when
// switching back
this.captions_el.textContent = '';
} }
reload_options(options) { reload_options(options) {
@ -1263,6 +1391,14 @@ class Player extends PrimaryView {
this.music_enabled = options.music_enabled ?? true; this.music_enabled = options.music_enabled ?? true;
this.sfx_player.volume = options.sound_volume ?? 1.0; this.sfx_player.volume = options.sound_volume ?? 1.0;
this.sfx_player.enabled = options.sound_enabled ?? true; this.sfx_player.enabled = options.sound_enabled ?? true;
if ([0, 1, 2].indexOf(options.spatial_mode) < 0) {
options.spatial_mode = 2;
}
this.sfx_player.spatial_mode = options.spatial_mode;
this.show_captions = options.show_captions ?? false;
if (! this.show_captions) {
this.captions_el.textContent = '';
}
this.renderer.use_cc2_anim_speed = options.use_cc2_anim_speed ?? false; this.renderer.use_cc2_anim_speed = options.use_cc2_anim_speed ?? false;
if (this.level) { if (this.level) {
@ -1640,6 +1776,16 @@ class Player extends PrimaryView {
} }
// Never try to draw past the next actual update // Never try to draw past the next actual update
this.renderer.draw(Math.min(0.999, update_progress)); this.renderer.draw(Math.min(0.999, update_progress));
// Update the SFX listener position, since it's inherently tied to the camera position,
// which only the renderer actually knows
this.sfx_player.set_listener_position(
this.renderer.viewport_x + this.renderer.viewport_size_x / 2,
this.renderer.viewport_y + this.renderer.viewport_size_y / 2,
);
// And move existing captions to match
this.update_caption_positions();
} }
render_inventory_tile(name) { render_inventory_tile(name) {
@ -2129,6 +2275,57 @@ class Player extends PrimaryView {
} }
} }
place_caption(cell, text) {
if (! this.show_captions)
return;
let span = mk('span.-caption', {}, text);
if (cell) {
// The given coordinates are the upper left of the cell the sound is coming from; shift
// to the center
span.setAttribute('data-x', cell.x + 0.5);
span.setAttribute('data-y', cell.y + 0.5);
}
else {
// This is a global sound; slap it in the center
// TODO well... we'll see how good an idea this is I guess
span.setAttribute('data-x', this.renderer.viewport_x + this.renderer.viewport_size_x / 2);
span.setAttribute('data-y', this.renderer.viewport_y + this.renderer.viewport_size_y / 2);
}
this._update_caption_position(span);
this.captions_el.append(span);
}
update_caption_positions() {
if (! this.show_captions)
return;
// There's an event handler on the container to delete these as soon as they finish
// animating, but I've had such event handlers be flaky before, so as an emergency measure:
// if the caption container gets full, nuke it
if (this.captions_el.childNodes.length > 100) {
this.captions_el.textContent = '';
}
for (let caption of this.captions_el.childNodes) {
this._update_caption_position(caption);
}
}
_update_caption_position(caption) {
let cx = parseFloat(caption.getAttribute('data-x'));
let cy = parseFloat(caption.getAttribute('data-y'));
// Move them relative to the viewport
let relx = cx - this.renderer.viewport_x;
let rely = cy - this.renderer.viewport_y;
// Cap them to not go past the edge of the viewport
relx = Math.max(0, Math.min(this.renderer.viewport_size_x, relx));
rely = Math.max(0, Math.min(this.renderer.viewport_size_y, rely));
// And some CSS calc() turns this into a useful position
caption.style.setProperty('--x-offset', relx);
caption.style.setProperty('--y-offset', rely);
}
// Auto-size the game canvas to fit the screen, if possible // Auto-size the game canvas to fit the screen, if possible
adjust_scale() { adjust_scale() {
// TODO make this optional // TODO make this optional
@ -2708,7 +2905,7 @@ class OptionsOverlay extends DialogOverlay {
let dl = mk('dl.formgrid'); let dl = mk('dl.formgrid');
this.main.append(dl); this.main.append(dl);
// Volume options // Simple options
dl.append( dl.append(
mk('dt', "Music volume"), mk('dt', "Music volume"),
mk('dd.option-volume', mk('dd.option-volume',
@ -2720,6 +2917,16 @@ class OptionsOverlay extends DialogOverlay {
mk('label', mk('input', {name: 'sound-enabled', type: 'checkbox'}), " Enabled"), mk('label', mk('input', {name: 'sound-enabled', type: 'checkbox'}), " Enabled"),
mk('input', {name: 'sound-volume', type: 'range', min: 0, max: 1, step: 0.05}), mk('input', {name: 'sound-volume', type: 'range', min: 0, max: 1, step: 0.05}),
), ),
mk('dt', "Spatial mode"),
mk('dd',
mk('select', {name: 'spatial-mode'},
mk('option', {value: '2'}, "Stereo — Full stereo panning"),
mk('option', {value: '1'}, "Mono — Change volume with distance"),
mk('option', {value: '0'}, "Off — Play sounds at full volume"),
),
),
mk('dt'),
mk('dd', mk('label', mk('input', {name: 'show-captions', type: 'checkbox'}), " Enable captions")),
mk('dt'), mk('dt'),
mk('dd', mk('label', mk('input', {name: 'use-cc2-anim-speed', type: 'checkbox'}), " Use CC2 animation speed")), mk('dd', mk('label', mk('input', {name: 'use-cc2-anim-speed', type: 'checkbox'}), " Use CC2 animation speed")),
); );
@ -2827,6 +3034,8 @@ class OptionsOverlay extends DialogOverlay {
this.root.elements['music-enabled'].checked = this.conductor.options.music_enabled ?? true; this.root.elements['music-enabled'].checked = this.conductor.options.music_enabled ?? true;
this.root.elements['sound-volume'].value = this.conductor.options.sound_volume ?? 1.0; this.root.elements['sound-volume'].value = this.conductor.options.sound_volume ?? 1.0;
this.root.elements['sound-enabled'].checked = this.conductor.options.sound_enabled ?? true; this.root.elements['sound-enabled'].checked = this.conductor.options.sound_enabled ?? true;
this.root.elements['spatial-mode'].value = this.conductor.options.spatial_mode ?? 2;
this.root.elements['show-captions'].checked = this.conductor.options.show_captions ?? false;
this.root.elements['use-cc2-anim-speed'].checked = this.conductor.options.use_cc2_anim_speed ?? false; this.root.elements['use-cc2-anim-speed'].checked = this.conductor.options.use_cc2_anim_speed ?? false;
this.root.elements['custom-tileset'].addEventListener('change', ev => { this.root.elements['custom-tileset'].addEventListener('change', ev => {
@ -2839,6 +3048,8 @@ class OptionsOverlay extends DialogOverlay {
options.music_enabled = this.root.elements['music-enabled'].checked; options.music_enabled = this.root.elements['music-enabled'].checked;
options.sound_volume = parseFloat(this.root.elements['sound-volume'].value); options.sound_volume = parseFloat(this.root.elements['sound-volume'].value);
options.sound_enabled = this.root.elements['sound-enabled'].checked; options.sound_enabled = this.root.elements['sound-enabled'].checked;
options.spatial_mode = parseInt(this.root.elements['spatial-mode'].value, 10);
options.show_captions = this.root.elements['show-captions'].checked;
options.use_cc2_anim_speed = this.root.elements['use-cc2-anim-speed'].checked; options.use_cc2_anim_speed = this.root.elements['use-cc2-anim-speed'].checked;
// Tileset stuff: slightly more complicated. Save custom ones to localStorage as data // Tileset stuff: slightly more complicated. Save custom ones to localStorage as data
@ -3221,7 +3432,6 @@ class PackTestDialog extends DialogOverlay {
async run(handle) { async run(handle) {
let pack = this.conductor.stored_game; let pack = this.conductor.stored_game;
let dummy_sfx = { let dummy_sfx = {
set_player_position() {},
play() {}, play() {},
play_once() {}, play_once() {},
}; };

View File

@ -33,6 +33,7 @@ main[hidden] {
input[type=radio], input[type=radio],
input[type=checkbox], input[type=checkbox],
input[type=range] { input[type=range] {
font-size: inherit;
margin: 0.125em; margin: 0.125em;
vertical-align: middle; vertical-align: middle;
} }
@ -100,6 +101,9 @@ button.--image {
button.--image img { button.--image img {
display: block; display: block;
} }
select {
font-size: inherit;
}
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: normal; font-weight: normal;
margin: 0; margin: 0;
@ -1608,6 +1612,51 @@ body.--debug .player-overlay-message {
font-size: 1.333em; font-size: 1.333em;
} }
/* Transparent container for displaying captions for captions */
.player-overlay-captions {
grid-area: level;
place-self: stretch;
position: relative;
/* above the message layer */
z-index: 3;
font-size: calc(0.75em * var(--scale));
pointer-events: none;
}
.player-overlay-captions > span.-caption {
position: absolute;
left: calc(var(--x-offset) * var(--tile-width) * var(--scale));
top: calc(var(--y-offset) * var(--tile-height) * var(--scale));
animation: 1s ease-in 1 forwards caption-fade;
font-weight: bold;
color: white;
/* Lol this sucks, please save me Tab */
/* TODO use an svg element for these instead? would also avoid overflow issues */
text-shadow:
-1px -1px black,
1px -1px black,
-1px 2px black,
1px 2px black,
/* one more to fix lowercase k! */
1px 0 black;
white-space: nowrap;
/* Anchor these to their absolute centers */
transform: translate(-50%, -50%);
}
@keyframes caption-fade {
0% {
opacity: 1;
}
75% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.player-level-number { .player-level-number {
grid-area: number; grid-area: number;
/* This is only for portrait, and mostly to fill space */ /* This is only for portrait, and mostly to fill space */