commit 5588d6f5eb69567caa33e0b2936c2e150996c8a3 Author: adrien Date: Sun Aug 25 22:09:29 2024 +0200 fist commit diff --git a/app.js b/app.js new file mode 100644 index 0000000..4af89f5 --- /dev/null +++ b/app.js @@ -0,0 +1,514 @@ +Array.prototype.remove = function(item) { + let index = this.indexOf(item) + if (index == 0) { + this.shift() + return true + } else if (index > 0) { + this.splice(index, index) + return true + } + else return false +} + +class Sprite { + constructor(canvasCtx, src, x, y, width, height, frames=1, zoom=2) { + this.canvasCtx = canvasCtx + this.sprite = new Image() + this.sprite.src = `img/${src}` + this.sx = 0 + this.sy = 0 + this.sWidth = width + this.sHeight = height + this.dWidth = zoom * width + this.dHeight = zoom * height + this.x = x + this.y = y + this.frames = frames + this.frame = 0 + this.animated = false + this.repeat = false + } + + set x(x) { + this._x = x + this.dx = x - (this.dWidth / 2) + } + + get x() { + return this._x + } + + set y(y) { + this._y = y + this.dy = y - (this.dHeight / 2) + } + + get y() { + return this._y + } + + play(repeat=false) { + this.animated = true + this.repeat = repeat + return new Promise((resolve, reject) => { + this.onanimationend = resolve + }) + } + + animate() { + if (this.animated) { + if (this.repeat) { + this.frame = (this.frame + 1) % this.frames + } else { + if (this.frame < this.frames -1) { + this.frame++ + } else { + this.onanimationend() + } + } + } + } + + onanimationend () {} + + draw() { + canvasCtx.drawImage( + this.sprite, + this.sx + this.frame * this.sWidth, + this.sy, + this.sWidth, + this.sHeight, + this.dx, + this.dy, + this.dWidth, + this.dHeight + ) + + this.animate() + } +} + + +class Canon extends Sprite { + constructor(canvasCtx, note) { + let sharp = [1, 3, 6, 8, 10].includes(note % 12) + super(canvasCtx, "canon.png", 34 * (note - FIRST_NOTE) + 66, sharp? 446:450, 11, 26, 4) + this.note = note + this.impactHeight = 9 + this.impactY = 0 + this.sy = sharp? 0 : this.sHeight + this.shooting = false + this.df = 1 + } + + draw() { + if (this.shooting) { + this.frame += this.df + if (this.frame >= this.frames) this.df = -1 + else if (this.frame <= 2) this.df = 1 + } else { + if (this.frame > 0) this.frame-- + } + super.draw() + if (this.frame) { + this.canvasCtx.drawImage(this.sprite, this.sWidth*(this.frame), 0, this.sWidth, 1, this.dx, this.impactY, 22, this.dy - this.impactY) + if (this.impactY) this.canvasCtx.drawImage(this.sprite, this.sWidth*(this.frame), 2*this.sHeight, this.sWidth, this.impactHeight, this.dx, this.impactY, this.dWidth, 2*this.impactHeight) + } + } +} + + +class Note extends Sprite { + constructor(canvasCtx, note, duration, sx, sy, width, height, frames, shotAnimationPeriod) { + super(canvasCtx, "note.png", 34 * (note - FIRST_NOTE) + 66, -40, width, height, frames, 1) + this.note = note + this.duration = duration + this.sx = sx + this.sy = sy + this.shotAnimationPeriod = shotAnimationPeriod + this.shot = false + this.time = 0 + } + + animate() { + if (this.shot) { + this.frame = Math.floor(this.time/this.shotAnimationPeriod) % this.frames + } else { + this.frame = Math.floor(this.time/10) % this.frames + } + this.time++ + } + + draw() { + if (this.shot && this.frame == 1) { + this.drawShot() + this.animate() + } else { + super.draw() + } + } + + drawShot() { + canvasCtx.drawImage(this.sprite, 0, 0, this.sWidth, this.sHeight, this.dx, this.dy, this.dWidth, this.dHeight) + } + + explose() { + playNoise(0.3, 0.4, 1400) + return new Sprite(this.canvasCtx, "tiny-explosion.png", this.x, this.y, 16, 16, 7) + } +} + + +class Sixteenth extends Note { + constructor(canvasCtx, note, duration) { + super(canvasCtx, note, duration, 21, 0, 21, 32, 2, 2) + } +} + + +class Eighth extends Note { + constructor(canvasCtx, note, duration) { + super(canvasCtx, note, duration, 42, 0, 21, 32, 2, 2) + } +} + + +class Quarter extends Note { + constructor(canvasCtx, note, duration) { + super(canvasCtx, note, duration, 36, 32, 28, 68, 2, 4) + } + + drawShot() { + canvasCtx.drawImage(this.sprite, 0, 40, 34, 37, this.dx+2, this.dy+16, 34, 37) + } + + explose() { + playNoise(0.5, 0.5, 1000) + return new Sprite(this.canvasCtx, "little-explosion.png", this.x, this.y, 33, 33, 5) + } +} + +class Whole extends Note { + constructor(canvasCtx, note, duration) { + super(canvasCtx, note, duration, 0, 78, 36, 40, 1) + } + + animate() {} + + explose() { + playNoise(0.8, 0.7, 400) + return new Sprite(canvasCtx, "big-explosion.png", this.x, this.y, 48, 48, 8) + } +} + + +const MAX_LEVEL = 2 +const UPDATE_PERIOD = 10 //ms +const FIRST_NOTE = 48 +const LAST_NOTE = 73 +const FREQUENCIES = [ + // C C♯ / D♭ D D♯ / E♭ E F F♯ / G♭ G G♯ / A♭ A A♯ / B♭ B + 16.35, 17.32, 18.35, 19.45, 20.6, 21.83, 23.12, 24.5, 25.96, 27.5, 29.14, 30.87, + 32.7, 34.65, 36.71, 38.89, 41.2, 43.65, 46.25, 49, 51.91, 55, 58.27, 61.74, + 65.41, 69.3, 73.42, 77.78, 82.41, 87.31, 92.5, 98, 103.83, 110, 116.54, 123.47, + 130.81, 138.59, 146.83, 155.56, 164.81, 174.61, 185, 196, 207.65, 220, 233.08, 246.94, + 261.63, 277.18, 293.66, 311.13, 329.63, 349.23, 369.99, 392, 415.3, 440, 466.16, 493.88, + 523.25, 554.37, 587.33, 622.25, 659.26, 698.46, 739.99, 783.99, 830.61, 880, 932.33, 987.77, + 1046.5, 1108.73, 1174.66, 1244.51, 1318.51, 1396.91, 1479.98, 1567.98, 1661.22, 1760, 1864.66, 1975.53, + 2093, 2217.46, 2349.32, 2489.02, 2637.02, 2793.83, 2959.96, 3135.96, 3322.44, 3520, 3729.31, 3951.07, + 4186.01, 4434.92, 4698.64, 4978.03, 5274.04, 5587.65, 5919.91, 6271.93, 6644.88, 7040, 7458.62, 7902.13, +] + +let keyMap = keyMapInput.value +let playing = false + +let canvasCtx = canvas.getContext("2d") +canvasCtx.mozImageSmoothingEnabled = false +canvasCtx.webkitImageSmoothingEnabled = false +canvasCtx.msImageSmoothingEnabled = false +canvasCtx.imageSmoothingEnabled = false + +let consoleSprite = new Sprite(canvasCtx, "console.png", canvas.width/2, 554, 480, 86) +let syntheSprite = new Sprite(canvasCtx, "synthe.png", canvas.width/2, 540, 110, 80) +let canonSprites = [] +for (let note=FIRST_NOTE; note canonSprite.draw()) + noteSprites.forEach(noteSprite => noteSprite.draw()) + syntheSprite.draw() + explosionSprites.forEach(explosionSprite => explosionSprite.draw()) +} + + +let audioCtx +let volume +let wave +let mod +let depth +let compressor +let oscillators = {} +function init() { + Tone.start() + + audioCtx = new AudioContext() + + compressor = audioCtx.createDynamicsCompressor() + compressor.threshold.setValueAtTime(-50, audioCtx.currentTime) + compressor.knee.setValueAtTime(40, audioCtx.currentTime) + compressor.ratio.setValueAtTime(12, audioCtx.currentTime) + compressor.attack.setValueAtTime(0, audioCtx.currentTime) + compressor.release.setValueAtTime(0.25, audioCtx.currentTime) + compressor.connect(audioCtx.destination) + + volume = audioCtx.createGain() + volRange.oninput() + volume.connect(compressor) + + mod = audioCtx.createOscillator() // the modulating oscillator + depth = audioCtx.createGain() // the modulator amplifier + modRange.oninput() + mod.frequency.value = 6 + mod.connect(depth) + mod.start() + + onpartialinput() + + showSettings() +} + +startDialog.onclose = init + +function showSettings() { + pause() + settingsDialog.showModal() +} + +function pause() { + Tone.Transport.pause() + window.clearInterval(updateTaskId) + playing = false +} + +settingsButton.onclick = showSettings + +keyMapInput.onchange = function(event) { + keyMap = keyMapInput.value +} + +volRange.oninput = function(event) { + volume.gain.linearRampToValueAtTime(volRange.value, audioCtx.currentTime) +} + +modRange.oninput = function(event) { + depth.gain.value = modRange.value +} + +function onpartialinput() { + wave = audioCtx.createPeriodicWave( + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0].concat(Array.from(document.querySelectorAll(".partial")).map(range => range.value)), + {disableNormalization: false,} + ) + for (const note in oscillators) { + oscillators[note].setPeriodicWave(wave) + } +} + +var midiIputs +midiSelect.onfocus = function() { + midiIputs = {} + midiSelect.innerHTML = `` + navigator.requestMIDIAccess().then( + midiAccess => { + if (midiAccess.inputs.size) { + for ([,input,] of midiAccess.inputs) { + midiIputs[input.id] = input + var option = document.createElement("option") + option.value = input.id + option.innerText = input.name + midiSelect.add(option) + input.onmidimessage = null + } + } + }, + error => { + console.log(error) + } + ) +} + +midiSelect.oninput = () => { + for (const id in midiIputs) midiIputs[id].onmidimessage = null + if (midiSelect.value) { + midiIputs[midiSelect.value].onmidimessage = onMIDIMessage + } +} + +function onMIDIMessage(event) { + let [code, note, velocity] = event.data + + if (144 <= code && code <= 159 && canonSprites[note]) { + //playNote(note, velocity / 128) + canonSprites[note].shooting = true + } else if (128 <= code && code <= 143 && canonSprites[note]) { + //stopNote(note) + canonSprites[note].shooting = false + } +} + +settingsDialog.onclose = newGame + +let level +function newGame() { + settingsDialog.onclose = resume + level = 0 + nextLevel() +} + +let midiSong +let noteSprites = [] +let explosionSprites = [] +async function nextLevel() { + level++ + midiSong = await Midi.fromUrl(`midi/${level}.mid`) + levelTitle.innerText = `Niveau ${level}` + songNameTitle.innerText = midiSong.name + noteSprites = [] + midiSong.tracks.forEach(track => { + //console.log(track.name) + track.notes.forEach(note => { + let noteSprite + let durationInQuarter = note.durationTicks / midiSong.header.ppq + if (durationInQuarter <= 0.25) noteSprite = new Sixteenth(canvasCtx, note.midi, 1000*note.duration) + else if (durationInQuarter <= 0.5) noteSprite = new Eighth(canvasCtx, note.midi, 1000*note.duration) + else if (durationInQuarter <= 1) noteSprite = new Quarter(canvasCtx, note.midi, 1000*note.duration) + else noteSprite = new Whole(canvasCtx, note.midi, 1000*note.duration) + Tone.Transport.scheduleOnce(time => noteSprites.push(noteSprite), note.time) + }) + }) + Tone.Transport.scheduleOnce(time => nextLevel, midiSong.duration) + + levelDialog.showModal() +} + +levelDialog.onclose = resume + +let updateTaskId +function resume() { + playing = true + Tone.Transport.start() + updateTaskId = window.setInterval(update, UPDATE_PERIOD) +} + +function update() { + noteSprites.forEach(noteSprite => { + noteSprite.y += 0.5 + }) + noteSprites.filter(noteSprite => noteSprite.y >= 420).forEach(noteSprite => { + stopNote(noteSprite.note) + let explosionSprite = noteSprite.explose() + explosionSprites.push(explosionSprite) + explosionSprite.play().then(() => explosionSprites.remove(explosionSprite)) + }) + noteSprites = noteSprites.filter(note => note.y < 420) + + canonSprites.forEach(canonSprite => { + let noteSprite = noteSprites.find(noteSprite => noteSprite.note == canonSprite.note) + if (noteSprite) { + noteSprite.shot = canonSprite.shooting + if (noteSprite.shot) { + noteSprite.duration -= UPDATE_PERIOD + if (noteSprite.duration > 0) { + playNote(canonSprite.note) + canonSprite.impactY = noteSprite.y + } else { + stopNote(canonSprite.note) + let explosionSprite = noteSprite.explose() + explosionSprites.push(explosionSprite) + explosionSprite.play().then(() => explosionSprites.remove(explosionSprite)) + noteSprites.remove(noteSprite) + } + } else { + stopNote(canonSprite.note) + } + } else { + stopNote(canonSprite.note) + canonSprite.impactY = 0 + } + }) +} + +function playNote(note, velocity=0.7) { + if(oscillators[note]) return + + var oscillator = audioCtx.createOscillator() + oscillator.frequency.value = FREQUENCIES[note] + oscillator.setPeriodicWave(wave) + + oscillator.velocity = audioCtx.createGain() + oscillator.velocity.gain.value = 0 + oscillator.velocity.gain.linearRampToValueAtTime(velocity, audioCtx.currentTime + 0.05) + oscillator.connect(oscillator.velocity) + oscillator.start() + oscillator.velocity.connect(volume) + + depth.connect(oscillator.detune) + + oscillators[note] = oscillator +} + +function stopNote(note) { + if(!oscillators[note]) return + + velocity = oscillators[note].velocity.gain.value + oscillators[note].velocity.gain.setValueCurveAtTime([velocity, velocity/10, velocity/20, 0], audioCtx.currentTime + 0.1, 0.5) + delete(oscillators[note]) +} + +function playNoise(noiseDuration, startGain=0.5, bandHz=1000) { + const bufferSize = audioCtx.sampleRate * noiseDuration + const noiseBuffer = new AudioBuffer({ + length: bufferSize, + sampleRate: audioCtx.sampleRate, + }) + const data = noiseBuffer.getChannelData(0) + for (let i = 0; i < bufferSize; i++) { + data[i] = Math.random() * 2 - 1; + } + const noise = new AudioBufferSourceNode(audioCtx, { + buffer: noiseBuffer, + }) + const bandpass = new BiquadFilterNode(audioCtx, { + type: "bandpass", + frequency: bandHz, + }) + const gain = new GainNode(audioCtx) + gain.gain.setValueCurveAtTime([startGain, startGain/5, 0], audioCtx.currentTime, noiseDuration) + noise.connect(bandpass).connect(gain).connect(audioCtx.destination) + noise.start() +} + +document.onkeydown = function(event) { + if (playing && keyMap.includes(event.key)) { + event.preventDefault() + let note = FIRST_NOTE + keyMap.indexOf(event.key) + canonSprites[note].shooting = true + } +} + +document.onkeyup = function(event) { + if (playing && keyMap.includes(event.key)) { + event.preventDefault() + let note = FIRST_NOTE + keyMap.indexOf(event.key) + canonSprites[note].shooting = false + } +} \ No newline at end of file diff --git a/img/big-explosion.png b/img/big-explosion.png new file mode 100644 index 0000000..ee800f8 Binary files /dev/null and b/img/big-explosion.png differ diff --git a/img/canon.png b/img/canon.png new file mode 100644 index 0000000..f246812 Binary files /dev/null and b/img/canon.png differ diff --git a/img/console.png b/img/console.png new file mode 100644 index 0000000..a8efb61 Binary files /dev/null and b/img/console.png differ diff --git a/img/favicon.png b/img/favicon.png new file mode 100644 index 0000000..9dd76ad Binary files /dev/null and b/img/favicon.png differ diff --git a/img/little-explosion.png b/img/little-explosion.png new file mode 100644 index 0000000..707bd54 Binary files /dev/null and b/img/little-explosion.png differ diff --git a/img/note.png b/img/note.png new file mode 100644 index 0000000..1d89d45 Binary files /dev/null and b/img/note.png differ diff --git a/img/notes.png b/img/notes.png new file mode 100644 index 0000000..65f7e40 Binary files /dev/null and b/img/notes.png differ diff --git a/img/pixel-city-chill.gif b/img/pixel-city-chill.gif new file mode 100644 index 0000000..90601b2 Binary files /dev/null and b/img/pixel-city-chill.gif differ diff --git a/img/synthe.png b/img/synthe.png new file mode 100644 index 0000000..febf9fe Binary files /dev/null and b/img/synthe.png differ diff --git a/img/tiny-explosion.png b/img/tiny-explosion.png new file mode 100644 index 0000000..2dbc264 Binary files /dev/null and b/img/tiny-explosion.png differ diff --git a/img/tiny-notes.png b/img/tiny-notes.png new file mode 100644 index 0000000..b0b4134 Binary files /dev/null and b/img/tiny-notes.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..9a82fbc --- /dev/null +++ b/index.html @@ -0,0 +1,93 @@ + + + + + Angry notes from outer space + + + + + + + + + + +
+ +
+ +
+

Angry notes from outer space

+
+
+

Jean-Michel, les notes furieuses de l'espace attaquent ! Détruisez-les à l'aide de votre harpe laser.

+

Ce jeu est conçu pour un clavier MIDI. Si vous en avez un, branchez-le maintenant.

+
+ +
+
+ +
+
+
+ +
+

Options

+
+

Contrôles

+
+ +
+ +
+ +
+
+
+

Son

+
+ + + + + + + + + + + + +
+
+ + + +
+
+ +
+

Niveau X

+

Titre

+
+ +
+
+
+ + + + \ No newline at end of file diff --git a/midi/1.mid b/midi/1.mid new file mode 100644 index 0000000..b2527ce Binary files /dev/null and b/midi/1.mid differ diff --git a/midi/2.mid b/midi/2.mid new file mode 100644 index 0000000..7f2c3b4 Binary files /dev/null and b/midi/2.mid differ diff --git a/style.css b/style.css new file mode 100644 index 0000000..6ae2f9d --- /dev/null +++ b/style.css @@ -0,0 +1,157 @@ +html, body, pre, code, kbd, samp { + font-family: "Press Start 2P", monospace;; +} + +body { + background-color: #212529; + font-size: 12; + display: flex; + align-content: center; + height: 100vh; + justify-content: center; + flex-wrap: wrap; +} + +body::before, +.nes-dialog::before { + content: " "; + display: block; + position: absolute; + left: 0; + top: 0; + background-image: repeating-linear-gradient(#0004, #0004 1px, transparent 1px, transparent 2px); + width: 100vw; + height: 100vh; + z-index: 2; + background-size: 100% 2px, 3px 100%; + pointer-events: none; +} + +.nes-dialog { + max-width: 90%; +} + +h1 { + text-shadow: 0 4px #adafbc; +} + +.is-centered { + text-align: center; +} + +textarea { + padding-right: 0; +} + +.sliders { + display: flex; + justify-content: center; + margin: 20px; +} + +.sliders label { + display: flex; + flex-flow: column; + margin: 5px; + align-items: center; + gap: 9px; +} + +input[type=range][orient=vertical] { + -webkit-appearance: slider-vertical; + width: 4px; + height: 100px; +} + +input[type=range] { + padding: 0; + width: 3px; +} + +input[type=range]::-moz-range-thumb { + border-radius: 0; + border: 3px solid white; +} + +input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + border-radius: 0; + border: 3px solid white; +} + +input[type=range]::-ms-thumb { + border-radius: 0; + border: 3px solid white; +} + +input[type=range]::-moz-range-thumb { + background: white; + border: white !important; +} + +input[type=range]::-moz-range-track { + background: transparent; + width: 3px; + border-left: 0; + border-top: 3px solid white; + border-bottom: 3px solid white; + border-radius: 0; + padding: -0 7px; + background-image: linear-gradient(white); +} + +input[type=range]::-webkit-slider-runnable-track { + background: transparent; + width: 3px; + border-left: 0; + border-top: 3px solid white; + border-bottom: 3px solid white; + border-radius: 0; + padding: -0 7px; + background-image: linear-gradient(white); +} + +input[type=range]::-ms-track { + background: transparent; + width: 3px; + border-left: 0; + border-top: 3px solid white; + border-bottom: 3px solid white; + border-radius: 0; + padding: -0 7px; + background-image: linear-gradient(white); +} + +#settingsButton { + position: fixed; + bottom: 25px; + right: 2rem; + box-shadow: 0 5px 20px rgba(0,0,0,.6); +} + +svg { + width: 32px; + height: 32px; +} + +.nes-balloon, .nes-balloon.is-dark, .nes-btn, .nes-container.is-rounded, .nes-container.is-rounded.is-dark, .nes-dialog.is-rounded, .nes-dialog.is-rounded.is-dark, .nes-progress, .nes-progress.is-rounded, .nes-table.is-bordered, .nes-table.is-dark.is-bordered, .nes-input, .nes-textarea, .nes-select select { + border-style: solid; + border-width: 2px; + border-image-outset: 2; +} + +.nes-btn { + padding: 4px 6px; +} + +.nes-btn:active:not(.is-disabled)::after { + box-shadow: inset 2px 2px #adafbc; +} + +.nes-btn:hover::after { + box-shadow: inset -2px -2px #adafbc; +} + +.nes-btn::after { + box-shadow: inset -2px -2px #adafbc; +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..1b420b1 --- /dev/null +++ b/test.html @@ -0,0 +1,50 @@ + + + + + + + + + \ No newline at end of file