diff --git a/js/game.js b/js/game.js index c84ec2c..d2e349c 100644 --- a/js/game.js +++ b/js/game.js @@ -361,6 +361,8 @@ export class Level { // TODO but maybe they should be undone anyway so rewind looks better this.player.is_blocked = false; + this.sfx.set_player_position(this.player.cell); + // First pass: tick cooldowns and animations; have actors arrive in their cells. We do the // arrival as its own mini pass, for one reason: if the player dies (which will end the game // immediately), we still want every time's animation to finish, or it'll look like some @@ -591,6 +593,7 @@ export class Level { // Track whether the player is blocked, for visual effect if (actor === this.player && p1_primary_direction && ! success) { + this.sfx.play_once('blocked'); actor.is_blocked = true; } @@ -648,6 +651,9 @@ export class Level { if (this.time_remaining <= 0) { this.fail('time'); } + else if (this.time_remaining % 20 === 0 && this.time_remaining < 30 * 20) { + this.sfx.play_once('tick'); + } } else { this.pending_undo.push(() => { @@ -831,6 +837,10 @@ export class Level { this.fail(actor.type.name); } + if (actor === this.player && goal_cell[0].type.name === 'floor') { + this.sfx.play_once('step-floor'); + } + if (this.compat.tiles_react_instantly) { this.step_on_cell(actor, actor.cell); } @@ -846,6 +856,12 @@ export class Level { continue; if (tile.type.is_item && this.give_actor(actor, tile.type.name)) { + if (tile.type.is_key) { + this.sfx.play_once('get-key', cell); + } + else { + this.sfx.play_once('get-tool', cell); + } this.remove_tile(tile); } else if (tile.type.is_teleporter) { @@ -873,9 +889,13 @@ export class Level { // XXX not especially undo-efficient this.remove_tile(actor); this.add_tile(actor, goal.cell); - if (this.attempt_step(actor, actor.direction)) + if (this.attempt_step(actor, actor.direction)) { // Success, teleportation complete + // Sound plays from the origin cell simply because that's where the sfx player + // thinks the player is currently + this.sfx.play_once('teleport', cell); break; + } if (goal === teleporter) // We've tried every teleporter, including the one they // stepped on, so leave them on it @@ -936,6 +956,7 @@ export class Level { collect_chip() { let current = this.chips_remaining; if (current > 0) { + this.sfx.play_once('get-chip'); this.pending_undo.push(() => this.chips_remaining = current); this.chips_remaining--; } @@ -972,6 +993,13 @@ export class Level { } fail(reason) { + if (reason === 'time') { + this.sfx.play_once('timeup'); + } + else { + this.sfx.play_once('lose'); + } + this.pending_undo.push(() => { this.state = 'playing'; this.fail_reason = null; @@ -984,6 +1012,7 @@ export class Level { } win() { + this.sfx.play_once('win'); this.pending_undo.push(() => this.state = 'playing'); this.state = 'success'; throw new GameEnded; diff --git a/js/main.js b/js/main.js index f9a5265..494f59b 100644 --- a/js/main.js +++ b/js/main.js @@ -219,6 +219,126 @@ const OBITUARIES = { "goo another way next time", ], }; +// Helper class used to let the game play sounds without knowing too much about the Player +class SFXPlayer { + constructor() { + this.ctx = new window.AudioContext; + this.player_x = null; + this.player_y = null; + this.sounds = {}; + this.sound_sources = { + // handcrafted + blocked: 'sfx/mmf.ogg', + // https://jummbus.bitbucket.io/#j2N04bombn110s0k0l00e00t3Mm4a3g00j07i0r1O_U00o30T0v0pL0OD0Ou00q1d1f8y0z2C0w2c0h2T2v0kL0OD0Ou02q1d1f6y1z2C1w1b4gp1b0aCTFucgds0 + bomb: 'sfx/bomb.ogg', + // https://jummbus.bitbucket.io/#j2N0cbutton-pressn100s0k0l00e00t3Mm1a3g00j07i0r1O_U0o3T0v0pL0OD0Ou00q1d1f3y1z1C2w0c0h0b4p1bJdn51eMUsS0 + 'button-press': 'sfx/button-press.ogg', + // https://jummbus.bitbucket.io/#j2N0ebutton-releasen100s0k0l00e00t3Mm1a3g00j07i0r1O_U0o3T0v0pL0OD0Ou00q1d1f3y1z1C2w0c0h0b4p1aArdkga4sG0 + 'button-release': 'sfx/button-release.ogg', + // https://jummbus.bitbucket.io/#j2N04doorn110s0k0l00e00t3Mmfa3g00j07i0r1O_U00o30T0v0zL0OD0Ou00q0d1f8y0z2C0w2c0h0T2v0pL0OD0Ou02q0d1f8y3ziC0w1b4gp1f0aqEQ0lCNzrYUY0 + door: 'sfx/door.ogg', + // https://jummbus.bitbucket.io/#j2N08get-chipn100s0k0l00e00t3Mmca3g00j07i0r1O_U0o4T0v0zL0OD0Ou00q1d1f6y1z2C0wac0h0b4p1dFyW7czgUK7aw0 + 'get-chip': 'sfx/get-chip.ogg', + // https://jummbus.bitbucket.io/#j2N07get-keyn100s0k0l00e00t3Mmfa3g00j07i0r1O_U0o5T0v0pL0OD0Ou00q1d5f8y0z2C0w1c0h0b4p1dFyW85CbwwzBg0 + 'get-key': 'sfx/get-key.ogg', + // https://jummbus.bitbucket.io/#j2N08get-tooln100s0k0l00e00t3Mm6a3g00j07i0r1O_U0o2T0v0pL0OD0Ou00q1d1f4y2z9C0w2c0h0b4p1bGqKNW4isVk0 + 'get-tool': 'sfx/get-tool.ogg', + // https://jummbus.bitbucket.io/#j2N06socketn110s0k0l00e00t3Mm4a3g00j07i0r1O_U00o30T5v0pL0OD0Ou05q1d1f8y1z7C1c0h0HU7000U0006000ET2v0pL0OD0Ou02q1d6f5y3z2C0w0b4gp1xGoKHGhFBcn2FyPkxk0rE2AGcNCQyHwUY0 + socket: 'sfx/socket.ogg', + // https://jummbus.bitbucket.io/#j2N06splashn110s0k0l00e00t3Mm5a3g00j07i0r1O_U00o20T0v0pL0OD0Ou00q0d0fay0z0C0w9c0h8T2v05L0OD0Ou02q2d6fay0z1C0w0b4gp1lGqKQy02gUY1qh7D1wb2Y0 + // https://jummbus.bitbucket.io/#j2N06splashn110s0k0l00e00t3Mm5a3g00j07i0r1O_U00o20T0v0pL0OD0Ou00q0d0fay0z0C0w9c0h8T2v05L0OD0Ou02q2d6fay0z1C0w0b4gp1lGqKQxw_zzM5F4us60IbM0 + splash: 'sfx/splash.ogg', + // https://jummbus.bitbucket.io/#j2N0astep-floorn100s0k0l00e00t3Mm6a3g00j07i0r1O_U0o1T0v05L0OD0Ou00q0d2f1y1zjC2w0c0h0b4p1aGaKaxqer00 + 'step-floor': 'sfx/step-floor.ogg', + // https://jummbus.bitbucket.io/#j2N08teleportn110s1k0l00e00t3Mm7a3g00j07i0r1O_U00o50T0v0pL0OD0Ou00q1d1f8y4z6C2w5c4h0T2v0kL0OD0Ou02q1d7f8y4z3C1w4b4gp1wF2Uzh5wdC18yHH4hhBhHwaATXu0Asds0 + teleport: 'sfx/teleport.ogg', + // https://jummbus.bitbucket.io/#j2N05thiefn100s1k0l00e00t3Mm3a3g00j07i0r1O_U0o1T0v0pL0OD0Ou00q1d1f5y1z8C2w2c0h0b4p1fFyUBBr9mGkKKds0 + thief: 'sfx/thief.ogg', + + // handcrafted + lose: 'sfx/bummer.ogg', + // https://jummbus.bitbucket.io/#j2N04tickn100s0k0l00e00t3Mmca3g00j07i0r1O_U0o2T0v0pL0OD0Ou00q1d1f7y1ziC0w4c0h4b4p1bKqE6Rtxex00 + tick: 'sfx/tick.ogg', + // https://jummbus.bitbucket.io/#j2N06timeupn100s0k0l00e00t3Mm4a3g00j07i0r1O_U0o3T1v0pL0OD0Ou01q1d5f4y1z8C1c0A0F0B0V1Q38e0Pa610E0861b4p1dIyfgKPcLucqU0 + timeup: 'sfx/timeup.ogg', + // https://jummbus.bitbucket.io/#j2N03winn200s0k0l00e00t2wm9a3g00j07i0r1O_U00o32T0v0EL0OD0Ou00q1d1f5y1z1C2w1c2h0T0v0pL0OD0Ou00q0d1f2y1z2C0w2c3h0b4gp1xFyW4xo31pe0MaCHCbwLbM5cFDgapBOyY0 + win: 'sfx/win.ogg', + }; + + for (let [name, path] of Object.entries(this.sound_sources)) { + this.init_sound(name, path); + } + + this.mmf_cooldown = 0; + } + + async init_sound(name, path) { + let buf = await fetch(path); + let audiobuf = await this.ctx.decodeAudioData(buf); + this.sounds[name] = { + buf: buf, + audiobuf: audiobuf, + }; + } + + set_player_position(cell) { + this.player_x = cell.x; + this.player_y = cell.y; + } + + play_once(name, cell = null) { + let data = this.sounds[name]; + if (! data) { + // Hasn't loaded yet, not much we can do + if (! this.sound_sources[name]) { + console.warn("Tried to play non-existent sound", name); + } + return; + } + + // "Mmf" can technically play every tic since bumping into something doesn't give a movement + // cooldown, so give it our own sound cooldown + if (name === 'blocked' && this.player_x !== null) { + if (this.mmf_cooldown > 0) { + return; + } + else { + this.mmf_cooldown = 4; + } + } + + let node = this.ctx.createBufferSource(); + node.buffer = data.audiobuf; + + if (cell && this.player_x !== null) { + // Reduce the volume for further-away sounds + let dx = cell.x - this.player_x; + let dy = cell.y - this.player_y; + let dist = Math.sqrt(dx*dx + dy*dy); + let gain = this.ctx.createGain(); + // x/(x + a) is a common and delightful way to get an easy asymptote and output between + // 0 and 1. Here, the result is above 80% for almost everything on screen; drops down + // to 50% for things 20 tiles away (which is, roughly, the periphery when standing in + // the center of a CC1 map), and bottoms out at 12.5% for standing in one corner of a + // CC2 map of max size and hearing something on the far opposite corner. + gain.gain.value = 1 - dist / (dist + 20); + node.connect(gain); + gain.connect(this.ctx.destination); + } + else { + // Play at full volume + node.connect(this.ctx.destination); + } + node.start(this.ctx.currentTime); + } + + // Reduce cooldowns + advance_tic() { + if (this.mmf_cooldown > 0) { + this.mmf_cooldown -= 1; + } + } +} class Player extends PrimaryView { constructor(conductor) { super(conductor, document.body.querySelector('main#player')); @@ -473,6 +593,13 @@ class Player extends PrimaryView { window.addEventListener('resize', ev => { this.adjust_scale(); }); + + // TODO yet another thing that should be in setup, but can't be because load_level is called + // first + this.sfx_player = new SFXPlayer; + } + + setup() { } activate() { @@ -495,6 +622,7 @@ class Player extends PrimaryView { load_level(stored_level) { this.level = new Level(stored_level, this.compat); + this.level.sfx = this.sfx_player; this.renderer.set_level(this.level); this.root.classList.toggle('--has-demo', !!this.level.stored_level.demo); // TODO base this on a hash of the UA + some identifier for the pack + the level index. StoredLevel doesn't know its own index atm... @@ -620,6 +748,7 @@ class Player extends PrimaryView { this.previous_input = input; + this.sfx_player.advance_tic(); this.level.advance_tic( this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null, this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null, diff --git a/js/tiletypes.js b/js/tiletypes.js index e739dae..8bdbd55 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -240,7 +240,7 @@ const TILE_TYPES = { is_swivel: true, on_depart(me, level, other) { if (other.direction === 'north') { - level.transmute_tile(me, 'swivel_ne'); + level.transmute_tile(me, 'swivel_sw'); } else if (other.direction === 'west') { level.transmute_tile(me, 'swivel_ne'); @@ -263,6 +263,7 @@ const TILE_TYPES = { }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, 'key_red')) { + level.sfx.play_once('door', me.cell); level.transmute_tile(me, 'floor'); } }, @@ -274,6 +275,7 @@ const TILE_TYPES = { }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, 'key_blue')) { + level.sfx.play_once('door', me.cell); level.transmute_tile(me, 'floor'); } }, @@ -285,6 +287,7 @@ const TILE_TYPES = { }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, 'key_yellow')) { + level.sfx.play_once('door', me.cell); level.transmute_tile(me, 'floor'); } }, @@ -296,6 +299,7 @@ const TILE_TYPES = { }, on_arrive(me, level, other) { if (level.take_key_from_actor(other, 'key_green')) { + level.sfx.play_once('door', me.cell); level.transmute_tile(me, 'floor'); } }, @@ -344,6 +348,7 @@ const TILE_TYPES = { draw_layer: LAYER_TERRAIN, on_arrive(me, level, other) { // TODO cc1 allows items under water, i think; water was on the upper layer + level.sfx.play_once('splash', me.cell); if (other.type.name === 'dirt_block') { level.transmute_tile(other, 'splash'); level.transmute_tile(me, 'dirt'); @@ -478,6 +483,7 @@ const TILE_TYPES = { level.fail('exploded'); } else { + level.sfx.play_once('bomb', me.cell); level.transmute_tile(other, 'explosion'); } }, @@ -487,6 +493,7 @@ const TILE_TYPES = { blocks_monsters: true, blocks_blocks: true, on_arrive(me, level, other) { + level.sfx.play_once('thief', me.cell); level.take_all_tools_from_actor(other); if (other.type.is_player) { level.adjust_bonus(0, 0.5); @@ -498,6 +505,7 @@ const TILE_TYPES = { blocks_monsters: true, blocks_blocks: true, on_arrive(me, level, other) { + level.sfx.play_once('thief', me.cell); level.take_all_keys_from_actor(other); if (other.type.is_player) { level.adjust_bonus(0, 0.5); @@ -576,6 +584,7 @@ const TILE_TYPES = { level.fail('exploded'); } else { + level.sfx.play_once('bomb', me.cell); level.transmute_tile(other, 'explosion'); } }, @@ -715,6 +724,8 @@ const TILE_TYPES = { button_blue: { draw_layer: LAYER_TERRAIN, on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + // Flip direction of all blue tanks for (let actor of level.actors) { // TODO generify somehow?? @@ -723,10 +734,15 @@ const TILE_TYPES = { } } }, + on_depart(me, level, other) { + level.sfx.play_once('button-release', me.cell); + }, }, button_yellow: { draw_layer: LAYER_TERRAIN, on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + // Move all yellow tanks one tile in the direction of the pressing actor for (let actor of level.actors) { // TODO generify somehow?? @@ -735,10 +751,15 @@ const TILE_TYPES = { } } }, + on_depart(me, level, other) { + level.sfx.play_once('button-release', me.cell); + }, }, button_green: { draw_layer: LAYER_TERRAIN, on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + // Swap green floors and walls // TODO could probably make this more compact for undo purposes for (let row of level.cells) { @@ -760,12 +781,17 @@ const TILE_TYPES = { } } }, + on_depart(me, level, other) { + level.sfx.play_once('button-release', me.cell); + }, }, button_brown: { draw_layer: LAYER_TERRAIN, connects_to: 'trap', connect_order: 'forward', on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + if (me.connection && me.connection.cell) { let trap = me.connection; level._set_prop(trap, 'open', true); @@ -782,6 +808,9 @@ const TILE_TYPES = { } }, on_depart(me, level, other) { + // TODO this doesn't play if you walk straight across + level.sfx.play_once('button-release', me.cell); + if (me.connection && me.connection.cell) { let trap = me.connection; level._set_prop(trap, 'open', false); @@ -798,10 +827,15 @@ const TILE_TYPES = { connects_to: 'cloner', connect_order: 'forward', on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + if (me.connection && me.connection.cell) { me.connection.type.activate(me.connection, level); } }, + on_depart(me, level, other) { + level.sfx.play_once('button-release', me.cell); + }, }, button_orange: { draw_layer: LAYER_TERRAIN, @@ -1233,6 +1267,7 @@ const TILE_TYPES = { }, on_arrive(me, level, other) { if (other.type.is_player && level.chips_remaining === 0) { + level.sfx.play_once('socket'); level.transmute_tile(me, 'floor'); } }, diff --git a/sfx/bomb.ogg b/sfx/bomb.ogg new file mode 100644 index 0000000..20a13d0 Binary files /dev/null and b/sfx/bomb.ogg differ diff --git a/sfx/bummer.ogg b/sfx/bummer.ogg new file mode 100644 index 0000000..e30112a Binary files /dev/null and b/sfx/bummer.ogg differ diff --git a/sfx/bummer.wav b/sfx/bummer.wav new file mode 100644 index 0000000..4f1517b Binary files /dev/null and b/sfx/bummer.wav differ diff --git a/sfx/button-press.ogg b/sfx/button-press.ogg new file mode 100644 index 0000000..7b60628 Binary files /dev/null and b/sfx/button-press.ogg differ diff --git a/sfx/button-release.ogg b/sfx/button-release.ogg new file mode 100644 index 0000000..645a6de Binary files /dev/null and b/sfx/button-release.ogg differ diff --git a/sfx/door.ogg b/sfx/door.ogg new file mode 100644 index 0000000..6f8ccfc Binary files /dev/null and b/sfx/door.ogg differ diff --git a/sfx/get-chip.ogg b/sfx/get-chip.ogg new file mode 100644 index 0000000..c181dec Binary files /dev/null and b/sfx/get-chip.ogg differ diff --git a/sfx/get-key.ogg b/sfx/get-key.ogg new file mode 100644 index 0000000..f45ccb4 Binary files /dev/null and b/sfx/get-key.ogg differ diff --git a/sfx/get-tool.ogg b/sfx/get-tool.ogg new file mode 100644 index 0000000..1413a5d Binary files /dev/null and b/sfx/get-tool.ogg differ diff --git a/sfx/mmf-high.ogg b/sfx/mmf-high.ogg new file mode 100644 index 0000000..7729d78 Binary files /dev/null and b/sfx/mmf-high.ogg differ diff --git a/sfx/mmf-orig.wav b/sfx/mmf-orig.wav new file mode 100644 index 0000000..5111391 Binary files /dev/null and b/sfx/mmf-orig.wav differ diff --git a/sfx/mmf.ogg b/sfx/mmf.ogg new file mode 100644 index 0000000..60409d6 Binary files /dev/null and b/sfx/mmf.ogg differ diff --git a/sfx/socket.ogg b/sfx/socket.ogg new file mode 100644 index 0000000..bdf9389 Binary files /dev/null and b/sfx/socket.ogg differ diff --git a/sfx/splash.ogg b/sfx/splash.ogg new file mode 100644 index 0000000..4ae7336 Binary files /dev/null and b/sfx/splash.ogg differ diff --git a/sfx/step-floor.ogg b/sfx/step-floor.ogg new file mode 100644 index 0000000..5f2c588 Binary files /dev/null and b/sfx/step-floor.ogg differ diff --git a/sfx/teleport.ogg b/sfx/teleport.ogg new file mode 100644 index 0000000..c3ee5eb Binary files /dev/null and b/sfx/teleport.ogg differ diff --git a/sfx/thief.ogg b/sfx/thief.ogg new file mode 100644 index 0000000..d842b92 Binary files /dev/null and b/sfx/thief.ogg differ diff --git a/sfx/tick.ogg b/sfx/tick.ogg new file mode 100644 index 0000000..19a948a Binary files /dev/null and b/sfx/tick.ogg differ diff --git a/sfx/timeup.ogg b/sfx/timeup.ogg new file mode 100644 index 0000000..e5075d6 Binary files /dev/null and b/sfx/timeup.ogg differ diff --git a/sfx/win.ogg b/sfx/win.ogg new file mode 100644 index 0000000..af5f880 Binary files /dev/null and b/sfx/win.ogg differ