├── bin └── advzip.exe ├── .gitattributes ├── assets ├── font.gif ├── wing.gif ├── flowers.gif ├── player.gif ├── particles.gif └── levels.json ├── src ├── Assets │ ├── levels.js │ ├── Font.js │ ├── WingSprite.js │ ├── ParticlesSprite.js │ ├── PlayerSprite.js │ └── FlowersSprite.js ├── Entities │ ├── Tile.js │ ├── GridEntity.js │ ├── Tileset.js │ ├── SolidTile.js │ ├── Fade.js │ ├── Particle.js │ ├── MainTitle.js │ ├── InfoText.js │ ├── Flash.js │ ├── Checkpoint.js │ ├── Player │ │ ├── DeathAnimation.js │ │ └── Wings.js │ ├── HurtTile.js │ ├── FinishAnimation.js │ ├── FadeBlock.js │ ├── Goal.js │ ├── MovingPlatform.js │ └── Player.js ├── Audio │ ├── Utility.js │ ├── Songs │ │ ├── Song1 │ │ │ ├── Kicks.js │ │ │ ├── Snares.js │ │ │ ├── HiHats.js │ │ │ ├── Strings.js │ │ │ ├── Bass.js │ │ │ └── Plucks.js │ │ └── Song1.js │ ├── Samples │ │ ├── Jump.js │ │ ├── Reboot.js │ │ ├── Death.js │ │ ├── ReverbIR.js │ │ ├── Dash.js │ │ └── Impact.js │ ├── MusicSamples │ │ ├── HiHat.js │ │ ├── Kick.js │ │ ├── Snare.js │ │ ├── Pluck.js │ │ └── Strings.js │ ├── Context.js │ ├── SongGeneration.js │ └── SoundGeneration.js ├── Editor │ ├── Entities │ │ ├── EditorEntity.js │ │ ├── EditorGoal.js │ │ ├── PlayerStart.js │ │ ├── EditorHurtTile.js │ │ ├── EditorSolidTile.js │ │ ├── EditorCheckpoint.js │ │ ├── EditorFadeBlock.js │ │ ├── EditorText.js │ │ └── EditorMovingPlatform.js │ ├── SerializerUtils │ │ ├── index.js │ │ └── RectangleCoverage.js │ ├── Brushes │ │ ├── Brush.js │ │ ├── RectangleBrush.js │ │ └── LineBrush.js │ ├── LevelLoaderEditor.js │ ├── LevelSerializer.js │ ├── LevelLoaderEditorPlayable.js │ ├── EditorWorld.js │ ├── EditorCamera.js │ └── EditorController.js ├── Graphics.js ├── editorEntry.js ├── entry.js ├── LevelLoaders │ ├── LevelLoaderBase.js │ └── LevelLoaderDefault.js ├── FSM.js ├── main.js ├── Audio.js ├── fontUtils.js ├── SceneManager.js ├── globals.js ├── Input.js ├── constants.js ├── Renderers │ ├── TileRenderer.js │ └── BackgroundRenderer.js ├── Renderer.js ├── Assets.js ├── Camera.js ├── utils.js └── World.js ├── screenshots ├── big.png └── small.png ├── .vscode └── settings.json ├── webpack.editor-config.js ├── webpack.config.js ├── README.md ├── package.json ├── index.html └── .gitignore /bin/advzip.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/bin/advzip.exe -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /assets/font.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/assets/font.gif -------------------------------------------------------------------------------- /assets/wing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/assets/wing.gif -------------------------------------------------------------------------------- /src/Assets/levels.js: -------------------------------------------------------------------------------- 1 | export { default as levels } from '../../assets/levels.json' 2 | -------------------------------------------------------------------------------- /assets/flowers.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/assets/flowers.gif -------------------------------------------------------------------------------- /assets/player.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/assets/player.gif -------------------------------------------------------------------------------- /screenshots/big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/screenshots/big.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.insertSpaces": true 4 | } 5 | -------------------------------------------------------------------------------- /assets/particles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/assets/particles.gif -------------------------------------------------------------------------------- /screenshots/small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sirxemic/js13k-game/HEAD/screenshots/small.png -------------------------------------------------------------------------------- /src/Assets/Font.js: -------------------------------------------------------------------------------- 1 | import FontGif from '../../assets/font.gif' 2 | 3 | export default { 4 | dataUrl: FontGif 5 | } 6 | -------------------------------------------------------------------------------- /src/Entities/Tile.js: -------------------------------------------------------------------------------- 1 | export class Tile { 2 | constructor (x, y, tags) { 3 | this.x = x 4 | this.y = y 5 | this.tags = tags 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /webpack.editor-config.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config') 2 | 3 | config.entry.app = './src/editorEntry.js' 4 | 5 | module.exports = config 6 | -------------------------------------------------------------------------------- /src/Assets/WingSprite.js: -------------------------------------------------------------------------------- 1 | import WingGif from '../../assets/wing.gif' 2 | 3 | export default { 4 | dataUrl: WingGif, 5 | frames: [ 6 | { x: 0, y: 0, w: 9, h: 8, oX: 10, oY: 8 } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/Audio/Utility.js: -------------------------------------------------------------------------------- 1 | export function decibelsToAmplitude (db) { 2 | return Math.pow(10, db / 20) 3 | } 4 | 5 | export function amplitudeToDecibels (amplitude) { 6 | return 20 * Math.log10(amplitude) 7 | } -------------------------------------------------------------------------------- /src/Editor/Entities/EditorEntity.js: -------------------------------------------------------------------------------- 1 | export class EditorEntity { 2 | constructor (x, y, w = 1, h = 1) { 3 | this.x = x 4 | this.y = y 5 | this.width = w 6 | this.height = h 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Editor/SerializerUtils/index.js: -------------------------------------------------------------------------------- 1 | import { RectangleCoverage } from './RectangleCoverage' 2 | 3 | export function makeRectangleCollection (width, height, map) { 4 | return new RectangleCoverage(width, height, map).solve() 5 | } -------------------------------------------------------------------------------- /src/Graphics.js: -------------------------------------------------------------------------------- 1 | export let TheCanvas = document.querySelector('canvas') 2 | export let TheGraphics = TheCanvas.getContext('2d') 3 | 4 | // Closure Compiler would rename the property if we don't set it like this 5 | TheGraphics['imageSmoothingEnabled'] = false 6 | -------------------------------------------------------------------------------- /src/editorEntry.js: -------------------------------------------------------------------------------- 1 | import { TheSceneManager } from './globals' 2 | import { start } from './main' 3 | import { LevelLoaderEditor } from './Editor/LevelLoaderEditor' 4 | 5 | start(async () => { 6 | await TheSceneManager.loadLevel(new LevelLoaderEditor(0)) 7 | }) 8 | -------------------------------------------------------------------------------- /src/entry.js: -------------------------------------------------------------------------------- 1 | 2 | import { TheSceneManager } from './globals' 3 | import { start } from './main' 4 | 5 | import { Song1 } from './Assets' 6 | import { LevelLoaderDefault } from './LevelLoaders/LevelLoaderDefault' 7 | 8 | start(async () => { 9 | Song1.play() 10 | await TheSceneManager.loadLevel(new LevelLoaderDefault(0)) 11 | }) 12 | -------------------------------------------------------------------------------- /src/Editor/Entities/EditorGoal.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class EditorGoal extends EditorEntity { 5 | constructor (x, y) { 6 | super(x, y, 1, 1) 7 | } 8 | 9 | render () { 10 | TheRenderer.drawRectangle('#f80', this.x * 8, this.y * 8, 8, 8) 11 | } 12 | } -------------------------------------------------------------------------------- /src/Editor/Entities/PlayerStart.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class PlayerStart extends EditorEntity { 5 | constructor (x, y) { 6 | super(x, y, 1, 1) 7 | } 8 | 9 | render () { 10 | TheRenderer.drawRectangle('#ff0', this.x * 8, this.y * 8, 8, 8) 11 | } 12 | } -------------------------------------------------------------------------------- /src/Editor/Entities/EditorHurtTile.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class EditorHurtTile extends EditorEntity { 5 | constructor (x, y) { 6 | super(x, y, 1, 1) 7 | } 8 | 9 | render () { 10 | TheRenderer.drawRectangle('#f0f', this.x * 8, this.y * 8, 8, 8) 11 | } 12 | } -------------------------------------------------------------------------------- /src/Editor/Entities/EditorSolidTile.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class EditorSolidTile extends EditorEntity { 5 | constructor (x, y) { 6 | super(x, y, 1, 1) 7 | } 8 | 9 | render () { 10 | TheRenderer.drawRectangle('#000', this.x * 8, this.y * 8, 8, 8) 11 | } 12 | } -------------------------------------------------------------------------------- /src/LevelLoaders/LevelLoaderBase.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, setTheWorld, setTheCamera } from '../globals' 2 | import { World } from '../World' 3 | import { Camera } from '../Camera' 4 | 5 | export class LevelLoaderBase { 6 | async load () { 7 | setTheWorld(new World()) 8 | setTheCamera(new Camera()) 9 | 10 | this.generate() 11 | 12 | await TheWorld.initEntities() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/Kicks.js: -------------------------------------------------------------------------------- 1 | import { 2 | addNotes, 3 | offsetNotes, 4 | repeatNotes 5 | } from '../../SongGeneration' 6 | import createKick from '../../MusicSamples/Kick' 7 | 8 | export default function createKickTrack (output, bpm) { 9 | let melodyNotes = offsetNotes(repeatNotes([[0], [2.5], [4], [5.5], [6.5]], 8, 8), 64) 10 | 11 | addNotes(melodyNotes, output, createKick, bpm) 12 | } 13 | -------------------------------------------------------------------------------- /src/Audio/Samples/Jump.js: -------------------------------------------------------------------------------- 1 | import { generateSound, bandPassFilter, applyEnvelope, sampleNoise } from '../SoundGeneration' 2 | 3 | export default function createSound () { 4 | const volumeEnvelope = [ 5 | [0, 0, 0.5], 6 | [0.1, 0.5, 0.2], 7 | [1, 0] 8 | ] 9 | 10 | return bandPassFilter( 11 | applyEnvelope(generateSound(0.2, sampleNoise), volumeEnvelope), 12 | 2000, 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/Snares.js: -------------------------------------------------------------------------------- 1 | import { 2 | addNotes, 3 | offsetNotes, 4 | repeatNotes 5 | } from '../../SongGeneration' 6 | import createSnare from '../../MusicSamples/Snare' 7 | 8 | export default function createSnareTrack (output, bpm) { 9 | let melodyNotes = offsetNotes(repeatNotes([[1], [3], [5], [7], [7.75]], 8, 8), 64) 10 | 11 | addNotes(melodyNotes, output, createSnare, bpm) 12 | } 13 | -------------------------------------------------------------------------------- /src/Audio/MusicSamples/HiHat.js: -------------------------------------------------------------------------------- 1 | import { 2 | generateSound, 3 | applyEnvelope, 4 | highPassFilter, 5 | sampleNoise 6 | } from '../SoundGeneration' 7 | 8 | export default function createSound (frequency, velocity) { 9 | let volumeEnvelope = [ 10 | [0, velocity, 0.2], 11 | [1, 0] 12 | ] 13 | 14 | return applyEnvelope(highPassFilter(generateSound(0.4, sampleNoise), 3000), volumeEnvelope) 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | entry: { 6 | app: ['./src/entry.js'] 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | filename: 'build.js' 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.gif$/, 16 | use: [{ loader: 'url-loader' }] 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Editor/Entities/EditorCheckpoint.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class EditorCheckpoint extends EditorEntity { 5 | render () { 6 | for (let i = 0; i < 5; i++) { 7 | TheRenderer.drawRectangle( 8 | `rgba(255,127,255,${1 - i / 5}`, 9 | this.x * 8, 10 | this.y * 8 - i * 8, 11 | this.width * 8, 12 | this.height * 8 13 | ) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/HiHats.js: -------------------------------------------------------------------------------- 1 | import { 2 | addNotes, 3 | offsetNotes, 4 | repeatNotes 5 | } from '../../SongGeneration' 6 | import createHiHat from '../../MusicSamples/HiHat' 7 | 8 | export default function createHiHatTrack (output, bpm) { 9 | let notes = offsetNotes(repeatNotes([[0, 0, 0.25], [0.5, 0, 1]], 1, 64), 64) 10 | 11 | notes = [...notes, ...offsetNotes(repeatNotes([[14.25, 0, 0.5], [15.25, 0, 0.5]], 16, 4), 64)] 12 | 13 | addNotes(notes, output, createHiHat, bpm) 14 | } 15 | -------------------------------------------------------------------------------- /src/Entities/GridEntity.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from '../constants' 2 | 3 | export class GridEntity { 4 | constructor (x, y, width=1, height=1) { 5 | this.x = x * TILE_SIZE 6 | this.y = y * TILE_SIZE 7 | 8 | this.width = width * TILE_SIZE 9 | this.height = height * TILE_SIZE 10 | } 11 | 12 | get cellX () { 13 | return this.x / TILE_SIZE 14 | } 15 | 16 | get cellY () { 17 | return this.y / TILE_SIZE 18 | } 19 | 20 | step () {} 21 | 22 | render () {} 23 | } 24 | -------------------------------------------------------------------------------- /src/Audio/Context.js: -------------------------------------------------------------------------------- 1 | export let TheAudioContext = new AudioContext() 2 | export let TheAudioDestination = TheAudioContext.createDynamicsCompressor() 3 | TheAudioDestination.knee.setValueAtTime(40, 0) 4 | TheAudioDestination.threshold.setValueAtTime(-12, 0) 5 | 6 | TheAudioDestination.connect(TheAudioContext.destination) 7 | 8 | export let TheReverbDestination 9 | 10 | export function setReverbDestination (reverb) { 11 | TheReverbDestination = reverb 12 | TheReverbDestination.connect(TheAudioDestination) 13 | } -------------------------------------------------------------------------------- /src/Assets/ParticlesSprite.js: -------------------------------------------------------------------------------- 1 | import ParticlesGif from '../../assets/particles.gif' 2 | 3 | export default { 4 | dataUrl: ParticlesGif, 5 | frames: [ 6 | { x: 0, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 7 | { x: 4, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 8 | { x: 8, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 9 | { x: 12, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 10 | { x: 16, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 11 | { x: 20, y: 0, w: 3, h: 3, oX: 1, oY: 1 }, 12 | { x: 24, y: 0, w: 3, h: 3, oX: 1, oY: 1 } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/Assets/PlayerSprite.js: -------------------------------------------------------------------------------- 1 | import PlayerGif from '../../assets/player.gif' 2 | 3 | export default { 4 | dataUrl: PlayerGif, 5 | frames: [ 6 | { x: 1, y: 0, w: 8, h: 13, oX: 4, oY: 13 }, 7 | { x: 10, y: 0, w: 9, h: 13, oX: 5, oY: 13 }, 8 | { x: 21, y: 0, w: 8, h: 13, oX: 4, oY: 13 }, 9 | { x: 30, y: 0, w: 10, h: 13, oX: 5, oY: 13 }, 10 | { x: 40, y: 0, w: 10, h: 13, oX: 5, oY: 13 }, 11 | { x: 50, y: 0, w: 10, h: 13, oX: 5, oY: 13 }, 12 | { x: 60, y: 0, w: 10, h: 13, oX: 5, oY: 13 } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/FSM.js: -------------------------------------------------------------------------------- 1 | export class FSM { 2 | constructor (fsm, initialState) { 3 | this.fsm = fsm 4 | this.activeState = initialState 5 | } 6 | 7 | setState (name, ...params) { 8 | this.fsm[this.activeState].leave && this.fsm[this.activeState].leave(this) 9 | 10 | this.activeState = name 11 | 12 | this.fsm[this.activeState].enter && this.fsm[this.activeState].enter(...params) 13 | } 14 | 15 | step () { 16 | this.fsm[this.activeState].execute && this.fsm[this.activeState].execute(this) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Assets/FlowersSprite.js: -------------------------------------------------------------------------------- 1 | import FlowersGif from '../../assets/flowers.gif' 2 | 3 | export default { 4 | dataUrl: FlowersGif, 5 | frames: [ 6 | { x: 0, y: 0, w: 13, h: 13, oX: 11, oY: 11 }, 7 | { x: 14, y: 0, w: 13, h: 9, oX: 7, oY: 1 }, 8 | { x: 28, y: 0, w: 16, h: 15, oX: 9, oY: 8 }, 9 | { x: 17, y: 10, w: 11, h: 9, oX: 10, oY: 8 }, 10 | { x: 0, y: 15, w: 16, h: 13, oX: 6, oY: 12 }, 11 | { x: 16, y: 20, w: 12, h: 8, oX: 2, oY: 7 }, 12 | { x: 29, y: 16, w: 15, h: 12, oX: 8, oY: 6 } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/Editor/Entities/EditorFadeBlock.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | 4 | export class EditorFadeBlock extends EditorEntity { 5 | render () { 6 | let x = this.x * 8 7 | let y = this.y * 8 8 | let width = this.width * 8 9 | let height = this.height * 8 10 | TheRenderer.drawRectangle('#000', x, y, width, height) 11 | TheRenderer.drawRectangle('#fff', x + 2, y + 2, width - 4, height - 4) 12 | TheRenderer.drawRectangle('#000', x + 3, y + 3, width - 6, height - 6) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Entities/Tileset.js: -------------------------------------------------------------------------------- 1 | export class Tileset { 2 | constructor () { 3 | this.tiles = {} 4 | } 5 | 6 | addTile (tile) { 7 | this.tiles[tile.x + ';' + tile.y] = tile 8 | } 9 | 10 | getTileAt (x, y, tags = 0) { 11 | let tile = this.tiles[x + ';' + y] 12 | if (!tile) { 13 | return null 14 | } 15 | if (tags) { 16 | return (tile.tags & tags) ? tile : null 17 | } 18 | 19 | return tile 20 | } 21 | 22 | forEachTile (callback) { 23 | for (let key in this.tiles) { 24 | callback(this.tiles[key]) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Editor/Entities/EditorText.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | import { drawText } from '../../fontUtils' 4 | import { TheGraphics } from '../../Graphics'; 5 | 6 | export class EditorText extends EditorEntity { 7 | constructor (x, y, text) { 8 | if (text.trim().length === 0) { 9 | text = 'ABCDEFG' 10 | } 11 | 12 | super( 13 | x, y, 14 | 1, 1 15 | ) 16 | this.text = text 17 | } 18 | 19 | render () { 20 | drawText(TheGraphics, this.text, this.x * 8, this.y * 8) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Entities/SolidTile.js: -------------------------------------------------------------------------------- 1 | import { TheColorScheme } from '../globals' 2 | import { Tile } from './Tile' 3 | import { TILE_SIZE, TAG_IS_SOLID } from '../constants' 4 | 5 | export class SolidTile extends Tile { 6 | constructor (x, y) { 7 | super(x, y, TAG_IS_SOLID) 8 | } 9 | 10 | render (tiles, ctx) { 11 | let xx = (this.x % 24 + 24) % 24 / 24 12 | let yy = (this.y % 24 + 24) % 24 / 24 13 | ctx.globalAlpha = 1 - xx * (1 - xx) * yy * (1 - yy) - 0.01 + Math.random() * 0.02 14 | ctx.fillStyle = TheColorScheme.fg 15 | ctx.fillRect(this.x * TILE_SIZE, this.y * TILE_SIZE, TILE_SIZE, TILE_SIZE) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Editor/Brushes/Brush.js: -------------------------------------------------------------------------------- 1 | export class Brush { 2 | constructor (action) { 3 | this.action = action 4 | 5 | this.drawing = false 6 | this.currentTileX = 0 7 | this.currentTileY = 0 8 | this.prevTileX = 0 9 | this.prevTileY = 0 10 | } 11 | 12 | start (x, y) { 13 | this.drawing = true 14 | this.prevTileX = this.currentTileX = x 15 | this.prevTileY = this.currentTileY = y 16 | } 17 | 18 | update (x, y) { 19 | this.prevTileX = this.currentTileX 20 | this.prevTileY = this.currentTileY 21 | this.currentTileX = x 22 | this.currentTileY = y 23 | } 24 | 25 | end () { 26 | this.drawing = false 27 | } 28 | } -------------------------------------------------------------------------------- /src/Audio/Samples/Reboot.js: -------------------------------------------------------------------------------- 1 | import { generateSound, applyEnvelope, sampleEnvelope, getFrequencyDelta, sampleTriangle, sampleSawtooth } from '../SoundGeneration' 2 | 3 | export default function createSound () { 4 | let phase = 0 5 | let freqEnvelope = [ 6 | [0, 440, 0.2], 7 | [1, 2200] 8 | ] 9 | function getNextSineSample (bufferPosition) { 10 | let freq = sampleEnvelope(bufferPosition, freqEnvelope) 11 | phase += getFrequencyDelta(freq) 12 | return (sampleTriangle(phase) + sampleSawtooth(phase)) / 2 13 | } 14 | const volumeEnvelope = [ 15 | [0, 0, 0.5], 16 | [0.1, 1, 0.5], 17 | [1, 0] 18 | ] 19 | 20 | return ( 21 | applyEnvelope(generateSound(0.5, getNextSineSample), volumeEnvelope) 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, TheSceneManager, setTheSceneManager, updateFrame } from './globals' 2 | import { Input } from './Input' 3 | import { loadAssets } from './Assets' 4 | import { SceneManager } from './SceneManager' 5 | 6 | function tick () { 7 | requestAnimationFrame(tick) 8 | 9 | if (TheSceneManager.step()) { 10 | return 11 | } 12 | 13 | step() 14 | 15 | Input.postUpdate() 16 | 17 | render() 18 | } 19 | 20 | function step () { 21 | TheWorld.step() 22 | 23 | updateFrame() 24 | } 25 | 26 | function render () { 27 | TheWorld.render() 28 | } 29 | 30 | export async function start (setup) { 31 | await loadAssets() 32 | 33 | setTheSceneManager(new SceneManager()) 34 | 35 | await setup() 36 | 37 | tick() 38 | } 39 | -------------------------------------------------------------------------------- /src/Audio/MusicSamples/Kick.js: -------------------------------------------------------------------------------- 1 | import { 2 | generateSound, 3 | applyEnvelope, 4 | getFrequencyDelta, 5 | sampleEnvelope, 6 | sampleSine, 7 | sampleTriangle 8 | } from '../SoundGeneration' 9 | 10 | export default function createSound () { 11 | let pitchEnvelope = [ 12 | [0, 380, 0.1], 13 | [0.8, 30] 14 | ] 15 | let volumeEnvelope = [ 16 | [0, 0], 17 | [0.01, 1, 0.2], 18 | [1, 0] 19 | ] 20 | 21 | let p = 0 22 | let sample 23 | function getNextSample (bufferPosition) { 24 | sample = (sampleSine(p) + sampleTriangle(p)) / 2 25 | p += getFrequencyDelta(sampleEnvelope(bufferPosition, pitchEnvelope)) 26 | return sample 27 | } 28 | 29 | return applyEnvelope(generateSound(0.4, getNextSample), volumeEnvelope) 30 | } 31 | -------------------------------------------------------------------------------- /src/Entities/Fade.js: -------------------------------------------------------------------------------- 1 | import { deltaTime } from '../globals' 2 | import { TheCanvas } from '../Graphics' 3 | import { TheRenderer } from '../Renderer' 4 | import { approach } from '../utils' 5 | 6 | export class Fade { 7 | constructor (color, alpha) { 8 | this.color = color 9 | this.alpha = alpha 10 | this.targetAlpha = 1 - alpha 11 | } 12 | 13 | step () { 14 | /* 15 | if (this.alpha === this.targetAlpha) { 16 | TheWorld.removeGuiEntity(this) 17 | } 18 | */ 19 | 20 | this.alpha = approach(this.alpha, this.targetAlpha, deltaTime) 21 | } 22 | 23 | render () { 24 | TheRenderer.setAlpha(this.alpha) 25 | TheRenderer.drawRectangle(this.color, 0, 0, TheCanvas.width, TheCanvas.height) 26 | TheRenderer.resetAlpha() 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Entities/Particle.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../Renderer' 2 | import { ParticlesSprite } from '../Assets' 3 | import { deltaTime, TheWorld } from '../globals' 4 | import { TheGraphics } from '../Graphics' 5 | import { randomInt } from '../utils' 6 | 7 | export class Particle { 8 | constructor (x, y) { 9 | this.x = x 10 | this.y = y 11 | this.spriteIndex = randomInt(ParticlesSprite.frames.length) 12 | this.timer = 1 13 | } 14 | 15 | step () { 16 | this.timer -= deltaTime 17 | if (this.timer <= 0) { 18 | TheWorld.removeEntity(this) 19 | } 20 | } 21 | 22 | render () { 23 | TheRenderer.setAlpha(0.5 * this.timer) 24 | TheRenderer.drawSprite(ParticlesSprite, this.x, this.y, this.spriteIndex) 25 | TheRenderer.resetAlpha() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Audio.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext, TheAudioDestination, TheReverbDestination } from './Audio/Context' 2 | 3 | export function playSample (sample, volume = 1, toReverb = false) { 4 | let source = TheAudioContext.createBufferSource() 5 | source.buffer = sample 6 | 7 | if (toReverb) { 8 | let gainNode = TheAudioContext.createGain() 9 | gainNode.gain.value = volume * 2 10 | source.connect(gainNode) 11 | gainNode.connect(TheReverbDestination) 12 | } 13 | 14 | if (volume !== 1) { 15 | let gainNode = TheAudioContext.createGain() 16 | gainNode.gain.setValueAtTime(volume, 0) 17 | source.connect(gainNode) 18 | source.onended = () => gainNode.disconnect(TheAudioDestination) 19 | gainNode.connect(TheAudioDestination) 20 | } else { 21 | source.connect(TheAudioDestination) 22 | } 23 | 24 | source.start() 25 | } 26 | -------------------------------------------------------------------------------- /src/Audio/Samples/Death.js: -------------------------------------------------------------------------------- 1 | import { 2 | getFrequencyDelta, 3 | applyEnvelope, 4 | sampleNoise, 5 | generateSound, 6 | sampleEnvelope 7 | } from '../SoundGeneration' 8 | 9 | export default function createSound () { 10 | let val = sampleNoise() 11 | let t = 0 12 | let freqEnvelope2 = [ 13 | [0, 2000, 0.2], 14 | [1, 200] 15 | ] 16 | function getNextStaticNoiseSample(bufferPosition) { 17 | if (t >= 1) { 18 | val = sampleNoise() 19 | t -= 1 20 | } 21 | let freq = sampleEnvelope(bufferPosition, freqEnvelope2) 22 | t += getFrequencyDelta(freq) 23 | return val 24 | } 25 | 26 | const volumeEnvelope = [ 27 | [0, 0.5, 0.5], 28 | [1, 0] 29 | ] 30 | 31 | return applyEnvelope( 32 | generateSound( 33 | 0.3, 34 | getNextStaticNoiseSample 35 | ), 36 | volumeEnvelope 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/Entities/MainTitle.js: -------------------------------------------------------------------------------- 1 | import { InfoText } from './InfoText' 2 | import { GAME_TITLE } from '../constants' 3 | import { ThePlayer, TheCamera, TheColorScheme } from '../globals' 4 | import { TheRenderer } from '../Renderer' 5 | import { TheGraphics } from '../Graphics' 6 | 7 | export class MainTitle { 8 | constructor () { 9 | this.text = new InfoText(0, 0, GAME_TITLE) 10 | } 11 | 12 | initialize () { 13 | this.text.x = ThePlayer.x - 48 14 | this.text.y = ThePlayer.y - 30 15 | 16 | return this.text.initialize() 17 | } 18 | 19 | step () {} 20 | 21 | render () { 22 | TheRenderer.setAlpha(1 - (1 - TheCamera.introZoomFactor) * (1 - TheCamera.introZoomFactor)) 23 | TheRenderer.drawRectangle(TheColorScheme.bg1, TheCamera.x - 300, TheCamera.y - 300, 600, 600) 24 | this.text.render() 25 | TheRenderer.resetAlpha() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Entities/InfoText.js: -------------------------------------------------------------------------------- 1 | import { TheColorScheme } from '../globals' 2 | import { TheRenderer } from '../Renderer' 3 | import { GridEntity } from './GridEntity' 4 | import { generateImage } from '../utils' 5 | import { getTextDimensions, drawText } from '../fontUtils' 6 | 7 | function getImage (text) { 8 | let { width, height } = getTextDimensions(text) 9 | return generateImage(width + 1, height + 1, ctx => { 10 | ctx.fillStyle = TheColorScheme.bg1 11 | ctx.fillRect(0, 0, width + 1, height + 1) 12 | drawText(ctx, text, 1, 1) 13 | }) 14 | } 15 | 16 | export class InfoText extends GridEntity { 17 | constructor (x, y, text) { 18 | super(x, y) 19 | this.text = text 20 | } 21 | 22 | async initialize () { 23 | this.renderable = await getImage(this.text) 24 | } 25 | 26 | render () { 27 | TheRenderer.drawImage(this.renderable, this.x, this.y) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Entities/Flash.js: -------------------------------------------------------------------------------- 1 | import { deltaTime, TheWorld } from '../globals' 2 | import { TheGraphics, TheCanvas } from '../Graphics' 3 | import { TheRenderer } from '../Renderer' 4 | 5 | const dashGradient = TheGraphics.createRadialGradient( 6 | TheCanvas.width / 2, 7 | TheCanvas.height / 2, 8 | 200, 9 | TheCanvas.width / 2, 10 | TheCanvas.height / 2, 11 | 1000 12 | ) 13 | 14 | dashGradient.addColorStop(0, '#eeeeee00') 15 | dashGradient.addColorStop(1, '#eeeeee53') 16 | 17 | export class Flash { 18 | constructor () { 19 | this.alpha = 1 20 | } 21 | 22 | step () { 23 | this.alpha -= deltaTime * 2 24 | if (this.alpha < 0) { 25 | TheWorld.removeGuiEntity(this) 26 | } 27 | } 28 | 29 | render () { 30 | TheRenderer.setAlpha(this.alpha) 31 | TheRenderer.drawRectangle(dashGradient, 0, 0, TheCanvas.width, TheCanvas.height) 32 | TheRenderer.resetAlpha() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Editor/LevelLoaderEditor.js: -------------------------------------------------------------------------------- 1 | import { setTheCamera, setTheWorld, setThePlayer, setTheEditorLevel } from '../globals' 2 | import { EditorWorld } from './EditorWorld' 3 | import { EditorCamera } from './EditorCamera' 4 | import { EditorController } from './EditorController' 5 | 6 | export class LevelLoaderEditor { 7 | constructor (levelNumber) { 8 | this.levelNumber = levelNumber 9 | 10 | setTheEditorLevel(this) 11 | 12 | this.loaded = false 13 | } 14 | 15 | async load () { 16 | if (!this.loaded) { 17 | this.world = new EditorWorld() 18 | this.camera = new EditorCamera() 19 | this.controller = new EditorController() 20 | this.world.setController(this.controller) 21 | 22 | this.loaded = true 23 | } 24 | 25 | setTheWorld(this.world) 26 | setTheCamera(this.camera) 27 | setThePlayer(null) 28 | 29 | this.controller.init(this.levelNumber) 30 | } 31 | } -------------------------------------------------------------------------------- /src/Audio/MusicSamples/Snare.js: -------------------------------------------------------------------------------- 1 | import { 2 | generateSound, 3 | applyEnvelope, 4 | getFrequencyDelta, 5 | sampleEnvelope, 6 | highPassFilter, 7 | sumSounds, 8 | bandPassFilter, 9 | sampleSine, 10 | sampleNoise 11 | } from '../SoundGeneration' 12 | 13 | export default function createSound () { 14 | let pitchEnvelope = [ 15 | [0, 400, 0.07], 16 | [1, 80] 17 | ] 18 | let volumeEnvelope = [ 19 | [0, 1, 0.2], 20 | [1, 0] 21 | ] 22 | 23 | let phase = 0 24 | let sample 25 | function getBodySample (bufferPosition) { 26 | sample = sampleSine(phase) 27 | phase += getFrequencyDelta(sampleEnvelope(bufferPosition, pitchEnvelope)) 28 | return sample 29 | } 30 | 31 | let body = generateSound(0.4, getBodySample) 32 | let noise = bandPassFilter(generateSound(0.4, sampleNoise), 3000, 1) 33 | 34 | return highPassFilter(applyEnvelope(sumSounds([body, noise]), volumeEnvelope), 400) 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Offline Paradise - A js13kGames submission 2 | 3 | This is a simple platformer created for the [js13kGames competition of 2018](https://2018.js13kgames.com/). The theme was "offline". 4 | 5 | ## About the code 6 | 7 | If you want to modify the code, just start by running 8 | 9 | ``` 10 | npm install && npm start 11 | ``` 12 | 13 | ## Creating levels 14 | 15 | There is also a level editor! To make your own levels, just run 16 | 17 | ``` 18 | npm run editor 19 | ``` 20 | 21 | How to use it is far from trivial, so here is a little manual: 22 | 23 | - Left click to draw or edit the properties of objects 24 | - Right click to erase 25 | - Use the number keys 1 until 8 to change what thing to draw or edit 26 | - Press space to play the level 27 | - Press Ctrl-C to copy the level's JSON to the clipboard 28 | - To save the level, add the copied JSON to `assets/levels.json` 29 | - To load a level, press 0 and then the index of the level in `assets/levels.json` 30 | -------------------------------------------------------------------------------- /src/Audio/Samples/ReverbIR.js: -------------------------------------------------------------------------------- 1 | import { applyEnvelope, generateSound, sampleNoise } from '../SoundGeneration' 2 | 3 | export default function createSound () { 4 | const e = 1e-7 5 | const delayDuration = 3/16 6 | function createDelayPattern (isLeft) { 7 | let t = 0 8 | let result = [] 9 | do { 10 | result.push([t, isLeft ? 1 : .45]) 11 | 12 | isLeft = !isLeft 13 | t += delayDuration 14 | 15 | result.push([t - e, 0]) 16 | } while (t <= 1) 17 | 18 | return result 19 | } 20 | const volumeEnvelope1 = createDelayPattern(true) 21 | const volumeEnvelope2 = createDelayPattern(false) 22 | 23 | const globalEnvelope = [ 24 | [0, 0, 0.5], 25 | [0.05, 1, 0.5], 26 | [1, 0] 27 | ] 28 | 29 | return [ 30 | applyEnvelope(applyEnvelope(generateSound(4, sampleNoise), volumeEnvelope1), globalEnvelope), 31 | applyEnvelope(applyEnvelope(generateSound(4, sampleNoise), volumeEnvelope2), globalEnvelope) 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/fontUtils.js: -------------------------------------------------------------------------------- 1 | import { Font } from './Assets' 2 | 3 | export function getTextDimensions (text) { 4 | let width = 0 5 | let height = 0 6 | let x = 0 7 | let y = 0 8 | for (let i = 0; i < text.length; i++) { 9 | if (text[i] === ' ') { 10 | x += 6 11 | } else if (text[i].match(/\n|#/)) { 12 | x = 0 13 | y += 6 14 | } else { 15 | x += 6 16 | width = Math.max(x, width) 17 | height = Math.max(y + 6, height) 18 | } 19 | } 20 | 21 | return { width, height } 22 | } 23 | 24 | export function drawText (ctx, text, x, y) { 25 | let px = x 26 | let py = y 27 | for (let i = 0; i < text.length; i++) { 28 | if (text[i] === ' ') { 29 | px += 6 30 | } else if (text[i].match(/\n|#/)) { 31 | px = x 32 | py += 6 33 | } else { 34 | let index = text.charCodeAt(i) - 65 35 | ctx.drawImage(Font.renderable, index * 6, 0, 5, 5, px, py, 5, 5) 36 | px += 6 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Entities/Checkpoint.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, ThePlayer } from '../globals' 2 | import { GridEntity } from './GridEntity' 3 | import { TILE_SIZE } from '../constants' 4 | import { overlapping } from '../utils' 5 | 6 | let OUT_OF_BOUNDS_MARGIN = 10 7 | 8 | let lastCheckpoint 9 | 10 | export class Checkpoint extends GridEntity { 11 | initialize () { 12 | this.respawnX = this.cellX 13 | this.respawnY = this.cellY 14 | 15 | let x0 = this.cellX 16 | let y0 = this.cellY 17 | let y1 = this.cellY 18 | while (y0 >= -OUT_OF_BOUNDS_MARGIN && !TheWorld.tiles.getTileAt(x0, y0 - 1)) { 19 | y0-- 20 | } 21 | this.y = y0 * TILE_SIZE 22 | this.height = (y1 - y0 + 1) * TILE_SIZE 23 | } 24 | 25 | step () { 26 | if (lastCheckpoint !== this && overlapping(this, ThePlayer.boundingBox)) { 27 | lastCheckpoint = this 28 | 29 | TheWorld.playerSpawnX = this.respawnX 30 | TheWorld.playerSpawnY = this.respawnY 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gclosurectest", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "test.js", 6 | "scripts": { 7 | "start": "webpack-dev-server -d -w", 8 | "editor": "webpack-dev-server -d -w --config webpack.editor-config.js", 9 | "build": "node build.js" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "css-loader": "^1.0.0", 15 | "get-pixels": "^3.3.2", 16 | "google-closure-compiler": "^20180716.0.1", 17 | "html-minifier": "^3.5.19", 18 | "jszip": "^3.1.5", 19 | "rollup": "^0.63.4", 20 | "rollup-plugin-json": "^3.0.0", 21 | "rollup-plugin-url": "^1.4.0", 22 | "tempfile": "^2.0.0", 23 | "url-loader": "^1.1.1", 24 | "vue": "^2.5.17", 25 | "vue-loader": "^15.3.0", 26 | "vue-style-loader": "^4.1.1", 27 | "vue-template-compiler": "^2.5.17", 28 | "webpack": "^4.16.3", 29 | "webpack-cli": "^3.1.0", 30 | "webpack-dev-server": "^3.1.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Editor/Brushes/RectangleBrush.js: -------------------------------------------------------------------------------- 1 | import { Brush } from './Brush' 2 | import { TheRenderer } from '../../Renderer' 3 | 4 | export class RectangleBrush extends Brush { 5 | start (x, y) { 6 | super.start(x, y) 7 | 8 | this.startTileX = x 9 | this.startTileY = y 10 | } 11 | 12 | end () { 13 | super.end() 14 | 15 | let x0 = Math.min(this.startTileX, this.currentTileX) 16 | let y0 = Math.min(this.startTileY, this.currentTileY) 17 | let x1 = x0 + Math.abs(this.startTileX - this.currentTileX) 18 | let y1 = y0 + Math.abs(this.startTileY - this.currentTileY) 19 | 20 | this.action(x0, y0, x1, y1) 21 | } 22 | 23 | render () { 24 | let x = Math.min(this.startTileX, this.currentTileX) 25 | let y = Math.min(this.startTileY, this.currentTileY) 26 | let w = Math.abs(this.startTileX - this.currentTileX) + 1 27 | let h = Math.abs(this.startTileY - this.currentTileY) + 1 28 | 29 | TheRenderer.drawRectangle('#222', x * 8, y * 8, w * 8, h * 8) 30 | } 31 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/Editor/Brushes/LineBrush.js: -------------------------------------------------------------------------------- 1 | import { Brush } from './Brush' 2 | 3 | function plotLine(x0, y0, x1, y1, callback) { 4 | let dx = x1 - x0 5 | let dy = y1 - y0 6 | 7 | if (dx === 0 && dy === 0) { 8 | return callback(x0, y0) 9 | } 10 | 11 | if (Math.abs(dx) < Math.abs(dy)) { 12 | return plotLine(y0, x0, y1, x1, (x, y) => callback(y, x)) 13 | } 14 | 15 | if (dx < 0) { 16 | return plotLine(x1, y1, x0, y0, callback) 17 | } 18 | 19 | let D = 2 * dy - dx 20 | let y = y0 21 | 22 | for (let x = x0; x <= x1; x++) { 23 | callback(x, y) 24 | if (D > 0) { 25 | y = y + 1 26 | D = D - 2 * dx 27 | } 28 | D = D + 2 * dy 29 | } 30 | } 31 | 32 | export class LineBrush extends Brush { 33 | start (x, y) { 34 | super.start(x, y) 35 | 36 | this.action(x, y) 37 | } 38 | 39 | update (x, y) { 40 | super.update(x, y) 41 | 42 | // plotLine(this.prevTileX, this.prevTileY, this.currentTileX, this.currentTileY, this.action) 43 | } 44 | 45 | render () { 46 | 47 | } 48 | } -------------------------------------------------------------------------------- /src/Audio/MusicSamples/Pluck.js: -------------------------------------------------------------------------------- 1 | import { generateSound, applyEnvelope, getFrequencyDelta, sampleSine, sampleNoise } from '../SoundGeneration' 2 | import { TheAudioContext } from '../Context' 3 | 4 | export default function createSound (frequency) { 5 | const delta = getFrequencyDelta(frequency) 6 | 7 | let p = 0 8 | let i = 0 9 | let sample 10 | let randomUpdatePeriod = 1 / 8000 11 | function getNextSample() { 12 | let value = ( 13 | 1 * sampleSine(p) + 14 | 1 * sampleSine(p / 2) + 15 | 0.1 * sampleSine(p * 2) + 16 | 0.001 * sampleSine(p * 3) + 17 | 0.0001 * sampleSine(p * 4) 18 | ) / 2.111 19 | 20 | sample = Math.round(5 * value) / 5 21 | 22 | p += delta 23 | 24 | i += 1 / TheAudioContext.sampleRate 25 | if (i >= randomUpdatePeriod) { 26 | p += sampleNoise() * delta * 0.6 27 | i -= randomUpdatePeriod 28 | } 29 | 30 | return sample 31 | } 32 | 33 | const volumeEnvelope = [ 34 | [0, 0, 0.5], 35 | [0.001, 1, 0.1], 36 | [1, 0] 37 | ] 38 | 39 | return applyEnvelope(generateSound(3, getNextSample), volumeEnvelope) 40 | } 41 | -------------------------------------------------------------------------------- /src/SceneManager.js: -------------------------------------------------------------------------------- 1 | import { LevelLoaderDefault } from './LevelLoaders/LevelLoaderDefault' 2 | 3 | export class SceneManager { 4 | constructor () { 5 | // this.currentLoader = null // commented out to save bytes (interpret undefined as null) 6 | // this.nextLoader = null // commented out to save bytes (interpret undefined as null) 7 | // this.loading = false // commented out to save bytes (interpret undefined as false) 8 | } 9 | 10 | step () { 11 | if (this.nextLoader) { 12 | this.loading = true 13 | this.loadLevel(this.nextLoader) 14 | this.nextLoader = null 15 | } 16 | 17 | return this.loading 18 | } 19 | 20 | loadNewLevel (loader) { 21 | this.nextLoader = loader 22 | } 23 | 24 | loadNextLevel () { 25 | if (!this.nextLoader) { 26 | this.nextLoader = new LevelLoaderDefault(this.currentLoader.levelNumber + 1) 27 | } 28 | } 29 | 30 | reloadLevel () { 31 | this.nextLoader = this.currentLoader 32 | } 33 | 34 | async loadLevel (loader) { 35 | this.currentLoader = loader 36 | 37 | this.loading = true 38 | 39 | await loader.load() 40 | 41 | this.loading = false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | -------------------------------------------------------------------------------- /src/Audio/Samples/Dash.js: -------------------------------------------------------------------------------- 1 | import { sumSounds, applyEnvelope, highPassFilter, bandPassFilter, sampleNoise, generateSound, sampleEnvelope } from '../SoundGeneration' 2 | import { TheAudioContext } from '../Context' 3 | 4 | export default function createSound () { 5 | const probabilityEnvelope = [ 6 | [0, 0.01, 2], 7 | [1, 0] 8 | ] 9 | const volumeEnvelope = [ 10 | [0, 0], 11 | [0.1, 1, 0.8], 12 | [0.2, 0.3, 0.9], 13 | [1, 0] 14 | ] 15 | 16 | let val = sampleNoise() 17 | 18 | function getNextSample1() { 19 | if (Math.random() < 5000 / TheAudioContext.sampleRate) { 20 | val = sampleNoise() 21 | } 22 | return val 23 | } 24 | 25 | function getNextSample2(bufferPosition) { 26 | if (Math.random() < sampleEnvelope(bufferPosition, probabilityEnvelope)) { 27 | val = sampleNoise() 28 | } 29 | return val 30 | } 31 | 32 | return applyEnvelope( 33 | sumSounds( 34 | [ 35 | highPassFilter( 36 | generateSound(0.3, getNextSample1), 37 | 900 38 | ), 39 | bandPassFilter( 40 | generateSound(0.3, getNextSample2), 41 | 100 42 | ) 43 | ] 44 | ), 45 | volumeEnvelope 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/Strings.js: -------------------------------------------------------------------------------- 1 | import { 2 | addNotes, 3 | offsetNotes 4 | } from '../../SongGeneration' 5 | import createStrings from '../../MusicSamples/Strings' 6 | 7 | export default function createStringsTrack (output, bpm) { 8 | const f = 60 / bpm 9 | let melodyNotes = offsetNotes([ 10 | [0, -8, 4 * f], 11 | [4, -7, 2 * f], 12 | [6, -5, 2 * f], 13 | [8, -3, 4 * f], 14 | [12, -1, 2 * f], 15 | [14, 0, 2 * f], 16 | [16, 0, 4 * f], 17 | [20, -1, 2 * f], 18 | [22, 0, 2 * f], 19 | [24, 2, 3 * f], 20 | [27, 0, 1 * f], 21 | [28, -1, 4 * f] 22 | ], 64).concat(offsetNotes([ 23 | [0, 0, 4 * f], 24 | [0, 4, 4 * f], 25 | [4, -1, 2 * f], 26 | [4, 2, 2 * f], 27 | [6, -3, 2 * f], 28 | [6, 0, 2 * f], 29 | [8, -3, 4 * f], 30 | [8, 0, 4 * f], 31 | [12, -3, 2 * f], 32 | [12, 0, 2 * f], 33 | [14, -5, 2 * f], 34 | [14, -1, 2 * f], 35 | [16, -7, 4 * f], 36 | [16, -3, 4 * f], 37 | [20, -5, 2 * f], 38 | [20, -1, 2 * f], 39 | [22, -3, 2 * f], 40 | [22, 0, 2 * f], 41 | [24, -5, 4 * f], 42 | [24, 0, 4 * f], 43 | [28, -5, 4 * f], 44 | [28, -1, 4 * f] 45 | ], 96)) 46 | 47 | addNotes(melodyNotes, output, createStrings, bpm) 48 | } 49 | -------------------------------------------------------------------------------- /src/globals.js: -------------------------------------------------------------------------------- 1 | export let deltaTime = 1 / 60 2 | 3 | export let TheSceneManager 4 | export let TheWorld 5 | export let ThePlayer 6 | export let TheCamera 7 | export let TheEditorLevel 8 | export let TheColorScheme = { 9 | bg1: null, 10 | bg2: null, 11 | bg3: null, 12 | fg: null 13 | } 14 | 15 | export let frame = 0 16 | 17 | export function setTheSceneManager (sceneManager) { 18 | TheSceneManager = sceneManager 19 | } 20 | 21 | export function setTheWorld (world) { 22 | TheWorld = world 23 | } 24 | 25 | export function setThePlayer (player) { 26 | ThePlayer = player 27 | } 28 | 29 | export function setTheCamera (camera) { 30 | TheCamera = camera 31 | } 32 | 33 | export function setTheEditorLevel (level) { 34 | TheEditorLevel = level 35 | } 36 | 37 | export function setColorScheme (levelNumber) { 38 | let hue = (240 + levelNumber * 70) % 360 39 | 40 | let makeColor = (h, s, l) => `hsl(${h}, ${s}%, ${l}%)` 41 | 42 | TheColorScheme.bg1 = makeColor(hue, 0, 93) 43 | hue += 20 44 | TheColorScheme.bg2 = makeColor(hue, 1, 70) 45 | hue += 20 46 | TheColorScheme.bg3 = makeColor(hue, 5, 49) 47 | hue += 20 48 | TheColorScheme.fg = makeColor(hue, 21, 17) 49 | } 50 | 51 | export function updateFrame () { 52 | frame++ 53 | } 54 | -------------------------------------------------------------------------------- /src/Editor/Entities/EditorMovingPlatform.js: -------------------------------------------------------------------------------- 1 | import { TheRenderer } from '../../Renderer' 2 | import { EditorEntity } from './EditorEntity' 3 | import { TheGraphics } from '../../Graphics' 4 | 5 | export class EditorMovingPlatform extends EditorEntity { 6 | constructor (x, y, w, h, xSpeed, ySpeed) { 7 | super(x, y, w, h) 8 | this.xSpeed = xSpeed 9 | this.ySpeed = ySpeed 10 | } 11 | 12 | render () { 13 | let centerX = this.x * 8 + this.width * 4 14 | let centerY = this.y * 8 + this.height * 4 15 | 16 | let x1, y1, x2, y2, x3, y3 17 | if (this.xSpeed) { 18 | x1 = -0.2 * this.xSpeed 19 | y1 = -4 20 | x2 = -0.2 * this.xSpeed 21 | y2 = +4 22 | x3 = +0.2 * this.xSpeed 23 | y3 = 0 24 | } else { 25 | x1 = -4 26 | y1 = -0.2 * this.ySpeed 27 | x2 = +4 28 | y2 = -0.2 * this.ySpeed 29 | x3 = 0 30 | y3 = +0.2 * this.ySpeed 31 | } 32 | 33 | TheRenderer.drawRectangle('#000', this.x * 8, this.y * 8, this.width * 8, this.height * 8) 34 | TheGraphics.fillStyle = '#fff' 35 | TheGraphics.beginPath() 36 | TheGraphics.moveTo(centerX + x1, centerY + y1) 37 | TheGraphics.lineTo(centerX + x2, centerY + y2) 38 | TheGraphics.lineTo(centerX + x3, centerY + y3) 39 | TheGraphics.fill() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Audio/Samples/Impact.js: -------------------------------------------------------------------------------- 1 | import { 2 | multiplySounds, 3 | getFrequencyDelta, 4 | applyEnvelope, 5 | sampleTriangle, 6 | sampleNoise, 7 | generateSound, 8 | sampleEnvelope, 9 | distort 10 | } from '../SoundGeneration' 11 | import { TheAudioContext } from '../Context' 12 | 13 | export default function createSound () { 14 | let phase = 0 15 | let freqEnvelope = [ 16 | [0, 40, 0.2], 17 | [1, 20] 18 | ] 19 | function getNextSineSample (bufferPosition) { 20 | let freq = sampleEnvelope(bufferPosition, freqEnvelope) 21 | phase += getFrequencyDelta(freq) 22 | return 40 * sampleTriangle(phase) 23 | } 24 | 25 | let val = sampleNoise() 26 | function getNextStaticNoiseSample() { 27 | if (Math.random() < 500 / TheAudioContext.sampleRate) { 28 | val = sampleNoise() 29 | } 30 | return val 31 | } 32 | 33 | const volumeEnvelope2 = [ 34 | [0, 1, 0.5], 35 | [1, 0] 36 | ] 37 | 38 | return multiplySounds( 39 | [ 40 | generateSound( 41 | 0.2, 42 | getNextSineSample 43 | ), 44 | 45 | applyEnvelope( 46 | distort( 47 | generateSound( 48 | 0.2, 49 | getNextStaticNoiseSample 50 | ), 51 | 3 52 | ), 53 | volumeEnvelope2 54 | ) 55 | ] 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/Audio/MusicSamples/Strings.js: -------------------------------------------------------------------------------- 1 | import { 2 | generateSound, 3 | sampleEnvelope, 4 | applyEnvelope, 5 | getFrequencyDelta, 6 | lowPassFilter 7 | } from '../SoundGeneration' 8 | 9 | export default function createSound (frequency, length) { 10 | const attack = 0.1 11 | const release = 0.2 12 | length += release 13 | const delta = getFrequencyDelta(frequency) 14 | 15 | const waveForm = [ 16 | [0, -0.5, 2], 17 | [0.1, 1, 2], 18 | [1, -1] 19 | ] 20 | 21 | function getSample (t) { 22 | return sampleEnvelope(t % 1, waveForm) 23 | } 24 | 25 | let p = 0 26 | let detune1 = 1.01 27 | let detune2 = 1 / 1.01 28 | function getNextSample () { 29 | let sample = getSample(p) / 2 30 | sample += getSample(p * detune1) / 3 31 | sample += getSample(p * detune2) / 6 32 | p += delta 33 | return sample 34 | } 35 | 36 | const volumeEnvelope = [ 37 | [0, 0], 38 | [attack / length, 1], 39 | [(length - release) / length, 0.5], 40 | [1, 0] 41 | ] 42 | 43 | const filterFreqEnvelope = [ 44 | [0, 200, 0.5], 45 | [attack / length, 2000, 0.5], 46 | [(length - release) / length, 1000, 0.5], 47 | [1, 200] 48 | ] 49 | 50 | return lowPassFilter( 51 | applyEnvelope( 52 | generateSound(length, getNextSample), 53 | volumeEnvelope 54 | ), 55 | filterFreqEnvelope 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/Bass.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext } from '../../Context' 2 | import { 3 | addNotes, 4 | zipRhythmAndNotes, 5 | offsetNotes, 6 | repeatNotes 7 | } from '../../SongGeneration' 8 | import createPluck from '../../MusicSamples/Pluck' 9 | 10 | function createMainLoop (bpm) { 11 | const beats = 32 12 | const lengthInSeconds = beats / bpm * 60 13 | const output = new Float32Array(lengthInSeconds * TheAudioContext.sampleRate) 14 | 15 | const bassRhythm = [ 0.5, 1.25 ] 16 | 17 | const bassPattern1 = zipRhythmAndNotes(bassRhythm, [ -12, -12 ]) 18 | const bassPattern2 = zipRhythmAndNotes(bassRhythm, [ -15, -15 ]) 19 | const bassPattern3 = zipRhythmAndNotes(bassRhythm, [ -19, -19 ]) 20 | const bassPattern4 = zipRhythmAndNotes(bassRhythm, [ -17, -17 ]) 21 | 22 | const pluckNotes = [ 23 | ...repeatNotes(bassPattern1, 2, 4), 24 | ...offsetNotes(repeatNotes(bassPattern2, 2, 4), 8), 25 | ...offsetNotes(repeatNotes(bassPattern3, 2, 4), 16), 26 | ...offsetNotes(repeatNotes(bassPattern4, 2, 4), 24) 27 | ] 28 | 29 | addNotes(pluckNotes, output, createPluck, bpm) 30 | 31 | return output 32 | } 33 | 34 | export default function createPlucksTrack (output, bpm) { 35 | const mainLoop = createMainLoop(bpm) 36 | 37 | output.set(mainLoop, 0) 38 | output.set(mainLoop, mainLoop.length) 39 | output.set(mainLoop, mainLoop.length * 2) 40 | output.set(mainLoop, mainLoop.length * 3) 41 | } 42 | -------------------------------------------------------------------------------- /src/Input.js: -------------------------------------------------------------------------------- 1 | import { 2 | LEFT_DIRECTION, 3 | RIGHT_DIRECTION, 4 | UP_DIRECTION, 5 | DOWN_DIRECTION, 6 | BOOST_TOGGLE, 7 | JUMP_OR_DASH 8 | } from './constants' 9 | 10 | export let Input = { 11 | current: {}, 12 | previous: {}, 13 | 14 | getKey (input) { 15 | return !!Input.current[input] 16 | }, 17 | 18 | getKeyDown (input) { 19 | return !!Input.current[input] && !Input.previous[input] 20 | }, 21 | 22 | getKeyUp (input) { 23 | return !Input.current[input] && !!Input.previous[input] 24 | }, 25 | 26 | postUpdate () { 27 | [ 28 | LEFT_DIRECTION, 29 | RIGHT_DIRECTION, 30 | UP_DIRECTION, 31 | DOWN_DIRECTION, 32 | BOOST_TOGGLE, 33 | JUMP_OR_DASH 34 | ].forEach(key => { 35 | Input.previous[key] = Input.current[key] 36 | }) 37 | } 38 | } 39 | 40 | let inputKeyMapping = { 41 | 37: LEFT_DIRECTION, 42 | 39: RIGHT_DIRECTION, 43 | 38: UP_DIRECTION, 44 | 40: DOWN_DIRECTION, 45 | 16: BOOST_TOGGLE, 46 | 90: JUMP_OR_DASH, 47 | 32: JUMP_OR_DASH 48 | } 49 | 50 | document.addEventListener('keydown', ({ keyCode }) => { 51 | let input = inputKeyMapping[keyCode] 52 | if (!input) { 53 | return 54 | } 55 | Input.previous[input] = Input.current[input] 56 | Input.current[input] = true 57 | }, false) 58 | 59 | document.addEventListener('keyup', ({ keyCode }) => { 60 | let input = inputKeyMapping[keyCode] 61 | if (!input) { 62 | return 63 | } 64 | Input.previous[input] = Input.current[input] 65 | Input.current[input] = false 66 | }, false) 67 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext } from '../Context' 2 | import { Song, createChannel } from '../SongGeneration' 3 | 4 | import createPlucksTrack from './Song1/Plucks' 5 | import createBassTrack from './Song1/Bass' 6 | import createStringsTrack from './Song1/Strings' 7 | import createSnareTrack from './Song1/Snares' 8 | import createKickTrack from './Song1/Kicks' 9 | import createHiHatTrack from './Song1/HiHats' 10 | 11 | import { decibelsToAmplitude } from '../Utility' 12 | 13 | export default async function createSong () { 14 | const bpm = 120 15 | const trackBeatCount = 32 * 4 16 | const sampleCount = trackBeatCount * 60 * TheAudioContext.sampleRate / bpm 17 | 18 | const [ 19 | channelPlucks, 20 | channelBass, 21 | channelStrings, 22 | channelSnare, 23 | channelKick, 24 | channelHihat 25 | ] = await Promise.all([ 26 | createPlucksTrack, 27 | createBassTrack, 28 | createStringsTrack, 29 | createSnareTrack, 30 | createKickTrack, 31 | createHiHatTrack 32 | ].map(func => createChannel(func, sampleCount, bpm))) 33 | 34 | return new Song( 35 | [ 36 | { source: channelPlucks, volume: decibelsToAmplitude(-14), sendToReverb: 1 }, 37 | { source: channelBass, volume: decibelsToAmplitude(-14), sendToReverb: 1 }, 38 | { source: channelStrings, volume: decibelsToAmplitude(-14), sendToReverb: 1 }, 39 | { source: channelSnare, volume: decibelsToAmplitude(-7.1), sendToReverb: decibelsToAmplitude(-6) }, 40 | { source: channelKick, volume: decibelsToAmplitude(-4.9), sendToReverb: 0 }, 41 | { source: channelHihat, volume: decibelsToAmplitude(-23), sendToReverb: 0 } 42 | ] 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/Audio/Songs/Song1/Plucks.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext } from '../../Context' 2 | import { 3 | addNotes, 4 | zipRhythmAndNotes, 5 | offsetNotes, 6 | addOctave 7 | } from '../../SongGeneration' 8 | import createPluck from '../../MusicSamples/Pluck' 9 | 10 | function createMainLoop (bpm) { 11 | const beats = 32 12 | const lengthInSeconds = beats / bpm * 60 13 | const output = new Float32Array(lengthInSeconds * TheAudioContext.sampleRate) 14 | 15 | const arpRhythm = [ 0, 0.75, 1.5, 2, 2.75, 3.5, 4, 4.75, 5.5, 6, 6.75, 7.5 ] 16 | 17 | const arp = [ 18 | ...zipRhythmAndNotes(arpRhythm, [ 19 | 4,7,11,4,7,11, 12,11,4,12,11,4 20 | ]), 21 | ...offsetNotes(zipRhythmAndNotes(arpRhythm, [ 22 | 4,7,11,4,7,11, 12,11,4,12,11,4 23 | ]), 8), 24 | ...offsetNotes(zipRhythmAndNotes(arpRhythm, [ 25 | 4,5,12,4,5,12, 12,11,4,12,11,4 26 | ]), 16), 27 | ...offsetNotes(zipRhythmAndNotes(arpRhythm, [ 28 | 2,7,11,2,7,11, 12,11,2,12,11,2 29 | ]), 24) 30 | ] 31 | arp.push( 32 | [30.5, 14] 33 | ) 34 | 35 | addNotes(arp, output, createPluck, bpm) 36 | 37 | return output 38 | } 39 | 40 | export default function createPlucksTrack (output, bpm) { 41 | const mainLoop = createMainLoop(bpm) 42 | 43 | output.set(mainLoop, 0) 44 | output.set(mainLoop, mainLoop.length) 45 | output.set(mainLoop, mainLoop.length * 2) 46 | output.set(mainLoop, mainLoop.length * 3) 47 | 48 | let melodyNotes = offsetNotes(addOctave([ 49 | [0, 16], 50 | [4, 17], 51 | [6, 19], 52 | [8, 21], 53 | 54 | [16, 24], 55 | [20, 23], 56 | [22, 24], 57 | [24, 19] 58 | ]), 32) 59 | 60 | addNotes(melodyNotes, output, createPluck, bpm) 61 | } 62 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const GAME_TITLE = 'OFFLINE PARADISE' 2 | 3 | export const TILE_SIZE = 8 4 | 5 | export const TAG_IS_SOLID = 1 6 | export const TAG_IS_DEATH = 2 7 | export const TAG_IS_COLLECTIBLE = 4 8 | 9 | // Input 10 | export const LEFT_DIRECTION = 1 11 | export const UP_DIRECTION = 2 12 | export const RIGHT_DIRECTION = 4 13 | export const DOWN_DIRECTION = 8 14 | export const BOOST_TOGGLE = 16 15 | export const JUMP_OR_DASH = 32 16 | 17 | // Player movement constants 18 | export const DASH_PREPARATION_TIME = 0.1 19 | export const DASH_DURATION = 0.25 20 | export const DASH_UP_SPEED = 240 21 | export const DASH_DOWN_SPEED = 320 22 | export const DASH_HORIZONTAL_SPEED = 240 23 | export const DASH_DIAGONAL_SPEED_X = 170 24 | export const DASH_DIAGONAL_SPEED_Y = 170 25 | 26 | export const DASH_FLOATING_DURATION = 0.08 27 | 28 | export const RUN_SPEED_HORIZONTAL = 160 29 | export const AIR_ACC_MULTIPLIER = 0.5 30 | export const RUN_ACCELERATION = 800 31 | export const RUN_BOOST_FRACTION = 0.1 32 | 33 | export const JUMP_SPEED = 128 34 | export const JUMP_FIRST_PHASE_DURATION = 0.4 35 | export const JUMP_FIRST_PHASE_GRAVITY = 160 36 | export const DEFAULT_GRAVITY = 720 37 | 38 | export const MAX_FALLING_SPEED = 320 39 | 40 | // World stuff 41 | export const BACKGROUND_LAYER = 120 42 | export const MAIN_LAYER = 100 43 | export const FOREGROUND_LAYER = 50 44 | 45 | // Serialization 46 | export const ENTITY_SERIALIZE_ID_SOLID_TILE = 1 47 | export const ENTITY_SERIALIZE_ID_HURT_TILE = 2 48 | export const ENTITY_SERIALIZE_ID_PLAYER_START = 3 49 | export const ENTITY_SERIALIZE_ID_MOVING_PLATFORM = 4 50 | export const ENTITY_SERIALIZE_ID_GOAL = 5 51 | export const ENTITY_SERIALIZE_ID_TEXT = 6 52 | export const ENTITY_SERIALIZE_ID_CHECKPOINT = 7 53 | export const ENTITY_SERIALIZE_ID_FADE_BLOCK = 8 54 | -------------------------------------------------------------------------------- /src/Entities/Player/DeathAnimation.js: -------------------------------------------------------------------------------- 1 | import { TheGraphics } from '../../Graphics' 2 | import { deltaTime, TheSceneManager, TheCamera, TheWorld } from '../../globals' 3 | import { playSample } from '../../Audio' 4 | import { DeathSound } from '../../Assets' 5 | import { TheRenderer } from '../../Renderer' 6 | 7 | class ExplosionCircle { 8 | constructor (color, startRadius, offset) { 9 | this.color = color 10 | 11 | let a = Math.random() * Math.PI * 2 12 | this.x = Math.cos(a) * offset 13 | this.y = Math.sin(a) * offset 14 | this.radius = startRadius 15 | this.alpha = 0 16 | this.alphaDelta = 1 / startRadius 17 | } 18 | 19 | render () { 20 | this.radius-- 21 | this.alpha += this.alphaDelta 22 | if (this.radius <= 0) { 23 | return 24 | } 25 | 26 | TheRenderer.drawCircle(this.color, null, this.x, this.y, this.radius) 27 | TheRenderer.setAlpha(this.alpha) 28 | TheRenderer.drawCircle('#888', null, this.x, this.y, this.radius) 29 | TheRenderer.resetAlpha() 30 | } 31 | } 32 | 33 | export class DeathAnimation { 34 | constructor (x, y) { 35 | this.x = x 36 | this.y = y 37 | this.timer = 0 38 | this.circles = [ 39 | { circle: new ExplosionCircle('#d48621', 18, 0), startAt: 0 }, 40 | { circle: new ExplosionCircle('#fff2aa', 9, 8), startAt: 0.1 }, 41 | { circle: new ExplosionCircle('#d48621', 9, 7), startAt: 0.2 }, 42 | { circle: new ExplosionCircle('#fff2aa', 9, 6), startAt: 0.3 } 43 | ] 44 | this.circleIndex = 0 45 | TheCamera.addShake(1) 46 | playSample(DeathSound) 47 | TheWorld.delay = 2 48 | } 49 | 50 | step () { 51 | this.timer += deltaTime 52 | if (this.timer >= 1) { 53 | TheWorld.respawnPlayer() 54 | } 55 | } 56 | 57 | render () { 58 | TheRenderer.drawAt(this.x, this.y, () => { 59 | this.circles.forEach(props => { 60 | if (this.timer < props.startAt) { 61 | return 62 | } 63 | props.circle.render() 64 | }) 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Renderers/TileRenderer.js: -------------------------------------------------------------------------------- 1 | import { TheCanvas } from '../Graphics' 2 | import { TheCamera } from '../globals' 3 | import { TheRenderer } from '../Renderer' 4 | import { generateImage, forRectangularRegion } from '../utils' 5 | 6 | const CHUNK_PIXEL_SIZE = 480 7 | const CHUNK_TILE_COUNT = CHUNK_PIXEL_SIZE / 8 8 | 9 | function getChunkCoord (x) { 10 | return Math.floor(x / 8 / CHUNK_TILE_COUNT) 11 | } 12 | 13 | export class TileRenderer { 14 | constructor (tiles) { 15 | this.chunks = {} 16 | this.tiles = tiles 17 | } 18 | 19 | async prerender () { 20 | this.tiles.forEachTile(tile => { 21 | let chunkX = Math.floor(tile.x / CHUNK_TILE_COUNT) 22 | let chunkY = Math.floor(tile.y / CHUNK_TILE_COUNT) 23 | let chunkKey = chunkX + ';' + chunkY 24 | this.chunks[chunkKey] = this.chunks[chunkKey] || { 25 | x: chunkX, 26 | y: chunkY, 27 | tiles: [] 28 | } 29 | this.chunks[chunkKey].tiles.push(tile) 30 | }) 31 | 32 | await Promise.all(Object.values(this.chunks).map(chunk => this.prerenderChunk(chunk))) 33 | } 34 | 35 | async prerenderChunk (chunk) { 36 | chunk.renderable = await generateImage(CHUNK_TILE_COUNT * 8, CHUNK_TILE_COUNT * 8, ctx => { 37 | ctx.translate(-chunk.x * CHUNK_PIXEL_SIZE, -chunk.y * CHUNK_PIXEL_SIZE) 38 | 39 | chunk.tiles.forEach(tile => tile.render(this.tiles, ctx)) 40 | }) 41 | } 42 | 43 | render () { 44 | let topIndex = getChunkCoord(TheCamera.viewYToWorldY(0)) 45 | let bottomIndex = getChunkCoord(TheCamera.viewYToWorldY(TheCanvas.height - 1)) 46 | let leftIndex = getChunkCoord(TheCamera.viewXToWorldX(0)) 47 | let rightIndex = getChunkCoord(TheCamera.viewXToWorldX(TheCanvas.width - 1)) 48 | 49 | forRectangularRegion(leftIndex, topIndex, rightIndex, bottomIndex, (xi, yi) => { 50 | let chunk = this.chunks[`${xi};${yi}`] 51 | if (chunk) { 52 | let x = xi * 8 * CHUNK_TILE_COUNT 53 | let y = yi * 8 * CHUNK_TILE_COUNT 54 | TheRenderer.drawImage(chunk.renderable, x, y) 55 | } 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Entities/HurtTile.js: -------------------------------------------------------------------------------- 1 | import { TheColorScheme } from '../globals' 2 | import { TILE_SIZE, TAG_IS_SOLID, TAG_IS_DEATH } from '../constants' 3 | import { Tile } from './Tile' 4 | 5 | export class HurtTile extends Tile { 6 | constructor (x, y) { 7 | super(x, y, TAG_IS_DEATH | TAG_IS_SOLID) 8 | } 9 | 10 | getIndex (tiles) { 11 | let x = this.x, y = this.y 12 | let verticalAlign = tiles.getTileAt(x, y - 1, TAG_IS_DEATH) || tiles.getTileAt(x, y + 1, TAG_IS_DEATH) 13 | let horizontalAlign = tiles.getTileAt(x - 1, y, TAG_IS_DEATH) || tiles.getTileAt(x + 1, y, TAG_IS_DEATH) 14 | if (tiles.getTileAt(x - 1, y, TAG_IS_SOLID) && verticalAlign) { 15 | return 3 16 | } 17 | if (tiles.getTileAt(x + 1, y, TAG_IS_SOLID) && verticalAlign) { 18 | return 1 19 | } 20 | if (tiles.getTileAt(x, y - 1, TAG_IS_SOLID) && horizontalAlign) { 21 | return 0 22 | } 23 | if (tiles.getTileAt(x, y + 1, TAG_IS_SOLID) && horizontalAlign) { 24 | return 2 25 | } 26 | if (tiles.getTileAt(x - 1, y, TAG_IS_SOLID)) { 27 | return 3 28 | } 29 | if (tiles.getTileAt(x + 1, y, TAG_IS_SOLID)) { 30 | return 1 31 | } 32 | if (tiles.getTileAt(x, y - 1, TAG_IS_SOLID)) { 33 | return 0 34 | } 35 | if (tiles.getTileAt(x, y + 1, TAG_IS_SOLID)) { 36 | return 2 37 | } 38 | 39 | return 0 40 | } 41 | 42 | render (tiles, ctx) { 43 | ctx.fillStyle = TheColorScheme.fg 44 | 45 | let x = this.x * TILE_SIZE 46 | let y = this.y * TILE_SIZE 47 | let index = this.getIndex(tiles) 48 | switch (index) { 49 | case 0: 50 | case 2: 51 | for (let i = 0; i < TILE_SIZE; i += 2) { 52 | let extra = (i / 2) % 2 53 | ctx.fillRect(x + i, y + (index === 2 ? extra : 0), 1, TILE_SIZE - extra) 54 | } 55 | break 56 | case 1: 57 | case 3: 58 | for (let i = 0; i < TILE_SIZE; i += 2) { 59 | let extra = (i / 2) % 2 60 | ctx.fillRect(x + (index === 1 ? extra : 0), y + i, TILE_SIZE - extra, 1) 61 | } 62 | break 63 | } 64 | ctx.stroke() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Entities/FinishAnimation.js: -------------------------------------------------------------------------------- 1 | import { TheSceneManager, ThePlayer, TheWorld } from '../globals' 2 | import { TheGraphics } from '../Graphics' 3 | import { TheRenderer } from '../Renderer' 4 | import { FlowersSprite } from '../Assets' 5 | import { Fade} from './Fade' 6 | 7 | let indexCounter = 0 8 | class Flower { 9 | constructor (x, y) { 10 | this.x = x 11 | this.y = y 12 | this.alpha = 0 13 | this.index = (indexCounter++) % FlowersSprite.frames.length 14 | } 15 | 16 | render () { 17 | TheRenderer.setAlpha(this.alpha) 18 | this.alpha += 0.1 19 | TheRenderer.drawSprite(FlowersSprite, this.x, this.y, this.index) 20 | } 21 | } 22 | 23 | let fibonacci = [1,2,3,5,8] 24 | 25 | export class FinishAnimation { 26 | constructor (isFinalLevel) { 27 | this.isFinalLevel = isFinalLevel 28 | this.centerX = ThePlayer.x 29 | this.centerY = ThePlayer.y - 6 30 | this.timer = 0 31 | this.flowers = [] 32 | this.scanners = [] 33 | 34 | for (let i = 0; i < 5; i++) { 35 | this.scanners.push({ r: 0, a: 0 }) 36 | } 37 | } 38 | 39 | step () { 40 | this.timer++ 41 | 42 | if (!this.isFinalLevel) { 43 | if (this.timer == 60) { 44 | TheWorld.addGuiEntity(new Fade('#fff', 0)) 45 | } 46 | 47 | if (this.timer == 120) { 48 | TheSceneManager.loadNextLevel() 49 | } 50 | } 51 | 52 | if (this.timer > 10 && this.timer < 240) { 53 | for (let i = 0; i < 5; i++) { 54 | let scanner = this.scanners[i] 55 | scanner.r += 2 56 | scanner.a += fibonacci[i] * (1 - this.timer / 240) 57 | let x = ThePlayer.x + Math.cos(scanner.a) * scanner.r 58 | let y = ThePlayer.y + Math.sin(scanner.a) * scanner.r 59 | 60 | if (TheWorld.solidAt(x - 1, y - 1, 2, 2)) { 61 | this.flowers.push(new Flower(x, y)) 62 | } 63 | } 64 | } 65 | } 66 | 67 | render () { 68 | this.flowers.forEach(flower => flower.render()) 69 | TheRenderer.resetAlpha() 70 | TheGraphics.lineWidth = 1 71 | TheRenderer.drawCircle(null, '#fff', this.centerX, this.centerY, Math.pow(this.timer, 1.125)) 72 | TheRenderer.drawCircle(null, '#fff', this.centerX, this.centerY, Math.pow(this.timer, 1.25)) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Renderer.js: -------------------------------------------------------------------------------- 1 | import { TheCamera } from './globals' 2 | import { TheCanvas, TheGraphics } from './Graphics' 3 | 4 | let drawCalls = 0 5 | export function resetDrawCallCount () { 6 | drawCalls = 0 7 | } 8 | 9 | export function incrementDrawCallCount () { 10 | drawCalls++ 11 | } 12 | 13 | export function printDrawCallCount () { 14 | console.log(drawCalls) 15 | } 16 | 17 | export const TheRenderer = { 18 | viewMatrix: [1, 0, 0, 1, 0, 0], 19 | 20 | clear () { 21 | TheGraphics.setTransform(1, 0, 0, 1, 0, 0) 22 | resetDrawCallCount() 23 | }, 24 | 25 | updateViewMatrix (z) { 26 | let scale = TheCamera.scale * 100 / z 27 | 28 | TheRenderer.viewMatrix[0] = scale 29 | TheRenderer.viewMatrix[3] = scale 30 | TheRenderer.viewMatrix[4] = TheCanvas.width / 2 - TheCamera.x * scale 31 | TheRenderer.viewMatrix[5] = TheCanvas.height / 2 - TheCamera.y * scale 32 | 33 | TheGraphics.setTransform(...TheRenderer.viewMatrix) 34 | }, 35 | 36 | setAlpha (alpha) { 37 | TheGraphics.globalAlpha = alpha 38 | }, 39 | 40 | resetAlpha () { 41 | TheGraphics.globalAlpha = 1 42 | }, 43 | 44 | drawImage (...args) { 45 | incrementDrawCallCount() 46 | TheGraphics.drawImage(...args) 47 | }, 48 | 49 | drawRectangle (fill, x, y, w, h) { 50 | incrementDrawCallCount() 51 | TheGraphics.fillStyle = fill 52 | TheGraphics.fillRect(x, y, w, h) 53 | }, 54 | 55 | drawCircle (fill, stroke, x, y, radius) { 56 | TheGraphics.beginPath() 57 | TheGraphics.arc(x, y, radius, 0, Math.PI * 2) 58 | if (fill) { 59 | TheGraphics.fillStyle = fill 60 | TheGraphics.fill() 61 | } 62 | if (stroke) { 63 | TheGraphics.strokeStyle = stroke 64 | TheGraphics.stroke() 65 | } 66 | }, 67 | 68 | drawSprite (obj, x, y, index = 0, scaleX = 1, scaleY = 1, rotation = 0) { 69 | TheRenderer.drawAt(x, y, () => { 70 | TheGraphics.rotate(rotation) 71 | TheGraphics.scale(scaleX, scaleY) 72 | 73 | const frame = obj.frames[index] 74 | 75 | TheRenderer.drawImage( 76 | obj.renderable, 77 | frame.x, 78 | frame.y, 79 | frame.w, 80 | frame.h, 81 | -frame.oX, 82 | -frame.oY, 83 | frame.w, 84 | frame.h 85 | ) 86 | }) 87 | }, 88 | 89 | drawAt (x, y, callback) { 90 | TheGraphics.save() 91 | 92 | TheGraphics.translate(x, y) 93 | 94 | callback() 95 | 96 | TheGraphics.restore() 97 | }, 98 | 99 | postProcessing () { 100 | // printDrawCallCount() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Renderers/BackgroundRenderer.js: -------------------------------------------------------------------------------- 1 | import { TheGraphics, TheCanvas } from '../Graphics' 2 | import { TheRenderer } from '../Renderer' 3 | import { generateImage, forRectangularRegion, randomInt, makeColorWithAlpha } from '../utils' 4 | import { TheColorScheme } from '../globals' 5 | 6 | const IMAGE_SIZE = 80 7 | 8 | class Layer { 9 | constructor (z, color) { 10 | this.z = z 11 | this.color = color 12 | 13 | const gradient = TheGraphics.createRadialGradient( 14 | TheCanvas.width / 2, 15 | TheCanvas.height / 2, 16 | 400, 17 | TheCanvas.width / 2, 18 | TheCanvas.height / 2, 19 | 1100 20 | ) 21 | 22 | gradient.addColorStop(0, makeColorWithAlpha(color, 0)) 23 | gradient.addColorStop(1, color) 24 | 25 | this.gradient = gradient 26 | } 27 | 28 | async prerender () { 29 | this.renderable = await generateImage(IMAGE_SIZE, IMAGE_SIZE, ctx => { 30 | ctx.fillStyle = this.color 31 | ctx.beginPath() 32 | for (let i = 0; i < IMAGE_SIZE; i++) { 33 | let w = 1 + randomInt(8) 34 | let h = 1 + randomInt(8) 35 | let x = randomInt(IMAGE_SIZE - w) 36 | let y = randomInt(IMAGE_SIZE - h) 37 | ctx.rect(x, y, w, h) 38 | } 39 | ctx.fill() 40 | }) 41 | } 42 | 43 | render () { 44 | let { z, renderable, gradient } = this 45 | 46 | TheRenderer.updateViewMatrix(z) 47 | 48 | let scale = z / 15 49 | let imageSize = IMAGE_SIZE * scale 50 | 51 | let bgLeft = -TheRenderer.viewMatrix[4] / TheRenderer.viewMatrix[0] 52 | let bgRight = TheCanvas.width / TheRenderer.viewMatrix[0] - TheRenderer.viewMatrix[4] / TheRenderer.viewMatrix[0] 53 | let bgTop = -TheRenderer.viewMatrix[5] / TheRenderer.viewMatrix[3] 54 | let bgBottom = TheCanvas.height / TheRenderer.viewMatrix[3] - TheRenderer.viewMatrix[5] / TheRenderer.viewMatrix[3] 55 | 56 | let xStart = Math.floor(bgLeft / imageSize) 57 | let xEnd = Math.floor(bgRight / imageSize) 58 | let yStart = Math.floor(bgTop / imageSize) 59 | let yEnd = Math.floor(bgBottom / imageSize) 60 | 61 | forRectangularRegion(xStart, yStart, xEnd, yEnd, (xi, yi) => { 62 | TheRenderer.drawImage( 63 | renderable, 64 | xi * imageSize, 65 | yi * imageSize, 66 | imageSize, 67 | imageSize 68 | ) 69 | }) 70 | 71 | TheGraphics.setTransform(1, 0, 0, 1, 0, 0) 72 | 73 | TheRenderer.drawRectangle(gradient, 0, 0, TheCanvas.width, TheCanvas.height) 74 | } 75 | } 76 | 77 | export class BackgroundRenderer { 78 | async prerender () { 79 | this.layers = [ 80 | new Layer(900, TheColorScheme.bg2), 81 | new Layer(300, TheColorScheme.bg3) 82 | ] 83 | 84 | await Promise.all(this.layers.map(layer => layer.prerender())) 85 | } 86 | 87 | render () { 88 | TheRenderer.drawRectangle(TheColorScheme.bg1, 0, 0, TheCanvas.width, TheCanvas.height) 89 | 90 | this.layers.forEach(layer => layer.render()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Editor/LevelSerializer.js: -------------------------------------------------------------------------------- 1 | import { TheWorld } from '../globals' 2 | import { 3 | ENTITY_SERIALIZE_ID_SOLID_TILE, 4 | ENTITY_SERIALIZE_ID_HURT_TILE, 5 | ENTITY_SERIALIZE_ID_PLAYER_START, 6 | ENTITY_SERIALIZE_ID_MOVING_PLATFORM, 7 | ENTITY_SERIALIZE_ID_GOAL, 8 | ENTITY_SERIALIZE_ID_TEXT, 9 | ENTITY_SERIALIZE_ID_CHECKPOINT, 10 | ENTITY_SERIALIZE_ID_FADE_BLOCK 11 | } from '../constants' 12 | 13 | import { makeRectangleCollection } from './SerializerUtils' 14 | 15 | import { EditorSolidTile } from './Entities/EditorSolidTile' 16 | import { EditorHurtTile } from './Entities/EditorHurtTile' 17 | import { PlayerStart } from './Entities/PlayerStart' 18 | import { EditorMovingPlatform } from './Entities/EditorMovingPlatform' 19 | import { EditorGoal } from './Entities/EditorGoal' 20 | import { EditorText } from './Entities/EditorText' 21 | import { EditorCheckpoint } from './Entities/EditorCheckpoint' 22 | import { EditorFadeBlock } from './Entities/EditorFadeBlock' 23 | 24 | export class LevelSerializer { 25 | constructor () {} 26 | 27 | serialize () { 28 | let { minX, maxX, minY, maxY } = TheWorld.getExtremes() 29 | 30 | let width = maxX - minX + 1 31 | let height = maxY - minY + 1 32 | 33 | let maps = { 34 | [ENTITY_SERIALIZE_ID_SOLID_TILE]: {}, 35 | [ENTITY_SERIALIZE_ID_HURT_TILE]: {} 36 | } 37 | let entities = [] 38 | 39 | TheWorld.forEachEntity(entity => { 40 | let x = entity.x - minX 41 | let y = entity.y - minY 42 | if (entity instanceof EditorSolidTile) { 43 | maps[ENTITY_SERIALIZE_ID_SOLID_TILE][x + ';' + y] = 1 44 | } 45 | if (entity instanceof EditorHurtTile) { 46 | maps[ENTITY_SERIALIZE_ID_HURT_TILE][x + ';' + y] = 1 47 | } 48 | if (entity instanceof PlayerStart) { 49 | entities.push([ENTITY_SERIALIZE_ID_PLAYER_START, x, y]) 50 | } 51 | if (entity instanceof EditorMovingPlatform) { 52 | entities.push([ENTITY_SERIALIZE_ID_MOVING_PLATFORM, x, y, entity.width, entity.height, entity.xSpeed, entity.ySpeed]) 53 | } 54 | if (entity instanceof EditorFadeBlock) { 55 | entities.push([ENTITY_SERIALIZE_ID_FADE_BLOCK, x, y, entity.width, entity.height]) 56 | } 57 | if (entity instanceof EditorGoal) { 58 | entities.push([ENTITY_SERIALIZE_ID_GOAL, x, y]) 59 | } 60 | if (entity instanceof EditorText) { 61 | entities.push([ENTITY_SERIALIZE_ID_TEXT, x, y, entity.text]) 62 | } 63 | if (entity instanceof EditorCheckpoint) { 64 | entities.push([ENTITY_SERIALIZE_ID_CHECKPOINT, x, y]) 65 | } 66 | }) 67 | 68 | for (let key in maps) { 69 | maps[key] = makeRectangleCollection(width, height, maps[key]) 70 | 71 | // Sort lexically for better compression 72 | maps[key].sort() 73 | } 74 | 75 | // Sort lexically for better compression 76 | entities.sort() 77 | 78 | return JSON.stringify({ 79 | maps, 80 | entities 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Entities/FadeBlock.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from '../constants' 2 | import { ThePlayer, deltaTime } from '../globals' 3 | import { generateImage, forRectangularRegion, renderSolidSquare, overlapping } from '../utils' 4 | import { TheRenderer } from '../Renderer' 5 | import { GridEntity } from './GridEntity' 6 | import { TheGraphics } from '../Graphics' 7 | 8 | const ANIMATION_DURATION = 0.2 9 | const RESPAWN_WAITING_DURATION = 15 10 | 11 | export class FadeBlock extends GridEntity { 12 | constructor (x, y, width, height) { 13 | super(x, y, width, height) 14 | 15 | this.reset() 16 | } 17 | 18 | reset () { 19 | this.collidable = true 20 | this.playerWasOnTop = false 21 | this.animationStep = 0 22 | } 23 | 24 | async initialize () { 25 | this.renderable = await generateImage(this.width, this.height, ctx => { 26 | forRectangularRegion(0, 0, this.width / TILE_SIZE - 1, this.height / TILE_SIZE - 1, (x, y) => { 27 | renderSolidSquare(ctx, x, y) 28 | }) 29 | 30 | const data = ctx.getImageData(0, 0, this.width, this.height) 31 | 32 | for (let x = 2; x < this.width - 2; x++) { 33 | data.data[4 * (x + 1 * this.width) + 3] = 0 34 | data.data[4 * (x + (this.height - 2) * this.width) + 3] = 0 35 | } 36 | 37 | for (let y = 2; y < this.height - 2; y++) { 38 | data.data[4 * (1 + y * this.width) + 3] = 0 39 | data.data[4 * (this.width - 2 + y * this.width) + 3] = 0 40 | } 41 | 42 | ctx.putImageData(data, 0, 0) 43 | }) 44 | } 45 | 46 | step () { 47 | if (!this.collidable) { 48 | this.animationStep += deltaTime 49 | if (this.animationStep >= RESPAWN_WAITING_DURATION) { 50 | this.reset() 51 | } else { 52 | return 53 | } 54 | } 55 | 56 | if (overlapping(this, ThePlayer.boundingBox)) { 57 | ThePlayer.die() 58 | return 59 | } 60 | 61 | let playerOnTop = ThePlayer.isRiding(this) 62 | 63 | if (playerOnTop && !this.playerWasOnTop) { 64 | this.playerWasOnTop = true 65 | } else if (!playerOnTop && this.playerWasOnTop) { 66 | this.collidable = false 67 | } 68 | } 69 | 70 | render () { 71 | let alpha 72 | if (this.animationStep < ANIMATION_DURATION) { 73 | alpha = 1 - this.animationStep / ANIMATION_DURATION 74 | } else if (this.animationStep > RESPAWN_WAITING_DURATION - ANIMATION_DURATION) { 75 | alpha = 1 - (RESPAWN_WAITING_DURATION - this.animationStep) / ANIMATION_DURATION 76 | } else { 77 | return 78 | } 79 | 80 | let hScale = 1 81 | let vScale = alpha 82 | let renderWidth = this.width * hScale 83 | let renderHeight = this.height * vScale 84 | let left = this.x + (this.width - renderWidth) / 2 85 | let top = this.y + (this.height - renderHeight) / 2 86 | TheRenderer.setAlpha((alpha + 1) / 2) 87 | TheRenderer.drawImage(this.renderable, left, top, renderWidth, renderHeight) 88 | TheRenderer.resetAlpha() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/LevelLoaders/LevelLoaderDefault.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, setColorScheme } from '../globals' 2 | import { 3 | ENTITY_SERIALIZE_ID_SOLID_TILE, 4 | ENTITY_SERIALIZE_ID_HURT_TILE, 5 | ENTITY_SERIALIZE_ID_PLAYER_START, 6 | ENTITY_SERIALIZE_ID_MOVING_PLATFORM, 7 | ENTITY_SERIALIZE_ID_GOAL, 8 | ENTITY_SERIALIZE_ID_TEXT, 9 | FOREGROUND_LAYER, 10 | BACKGROUND_LAYER, 11 | ENTITY_SERIALIZE_ID_CHECKPOINT, 12 | ENTITY_SERIALIZE_ID_FADE_BLOCK 13 | } from '../constants' 14 | import { forRectangularRegion } from '../utils' 15 | 16 | import { LevelLoaderBase } from './LevelLoaderBase' 17 | 18 | import { Player } from '../Entities/Player' 19 | import { SolidTile } from '../Entities/SolidTile' 20 | import { HurtTile } from '../Entities/HurtTile' 21 | import { MovingPlatform } from '../Entities/MovingPlatform' 22 | import { Goal } from '../Entities/Goal' 23 | import { InfoText } from '../Entities/InfoText' 24 | import { FinishAnimation } from '../Entities/FinishAnimation' 25 | 26 | import { levels } from '../Assets/levels' 27 | import { MainTitle } from '../Entities/MainTitle' 28 | import { Checkpoint } from '../Entities/Checkpoint' 29 | import { FadeBlock } from '../Entities/FadeBlock' 30 | 31 | export class LevelLoaderDefault extends LevelLoaderBase { 32 | constructor (levelNumber) { 33 | super() 34 | this.levelNumber = levelNumber 35 | } 36 | 37 | generate () { 38 | const { maps, entities } = levels[this.levelNumber] 39 | 40 | setColorScheme(this.levelNumber) 41 | 42 | for (let key in maps) { 43 | let entityType = { 44 | [ENTITY_SERIALIZE_ID_SOLID_TILE]: SolidTile, 45 | [ENTITY_SERIALIZE_ID_HURT_TILE]: HurtTile 46 | }[key] 47 | maps[key].forEach(([x, y, w, h]) => { 48 | forRectangularRegion(x, y, x + w - 1, y + h - 1, (xi, yi) => TheWorld.addTile(new entityType(xi, yi))) 49 | }) 50 | } 51 | 52 | entities.forEach(entity => { 53 | const args = entity.slice(1) 54 | switch (entity[0]) { 55 | case ENTITY_SERIALIZE_ID_PLAYER_START: 56 | TheWorld.setPlayer(new Player(...args)) 57 | break 58 | case ENTITY_SERIALIZE_ID_MOVING_PLATFORM: 59 | TheWorld.addSolidEntity(new MovingPlatform(...args)) 60 | break 61 | case ENTITY_SERIALIZE_ID_FADE_BLOCK: 62 | TheWorld.addSolidEntity(new FadeBlock(...args)) 63 | break 64 | case ENTITY_SERIALIZE_ID_GOAL: 65 | TheWorld.addEntity(new Goal(...args)) 66 | break 67 | case ENTITY_SERIALIZE_ID_TEXT: 68 | TheWorld.addEntity(new InfoText(...args), BACKGROUND_LAYER) 69 | break 70 | case ENTITY_SERIALIZE_ID_CHECKPOINT: 71 | TheWorld.addEntity(new Checkpoint(...args)) 72 | break 73 | } 74 | }) 75 | 76 | if (this.levelNumber === 0) { 77 | TheWorld.addEntity(new MainTitle(), FOREGROUND_LAYER) 78 | } 79 | 80 | if (this.levelNumber === levels.length - 1) { 81 | TheWorld.addEntity(new FinishAnimation(true)) 82 | } 83 | 84 | TheWorld.updateDimensions() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Assets.js: -------------------------------------------------------------------------------- 1 | import _FlowersSprite from './Assets/FlowersSprite' 2 | import _Font from './Assets/Font' 3 | import _ParticlesSprite from './Assets/ParticlesSprite' 4 | import _PlayerSprite from './Assets/PlayerSprite' 5 | import _WingSprite from './Assets/WingSprite' 6 | 7 | import createDashSound from './Audio/Samples/Dash' 8 | import createImpactSound from './Audio/Samples/Impact' 9 | import createJumpSound from './Audio/Samples/Jump' 10 | import createDeathSound from './Audio/Samples/Death' 11 | import createRebootSound from './Audio/Samples/Reboot' 12 | import createReverbIR from './Audio/Samples/ReverbIR' 13 | import createSong1 from './Audio/Songs/Song1' 14 | 15 | import { TheAudioContext, setReverbDestination } from './Audio/Context' 16 | import { waitForNextFrame } from './utils' 17 | 18 | export let FlowersSprite 19 | export let Font 20 | export let ParticlesSprite 21 | export let PlayerSprite 22 | export let WingSprite 23 | 24 | export let DashSound 25 | export let ImpactSound 26 | export let JumpSound 27 | export let DeathSound 28 | export let RebootSound 29 | export let Song1 30 | 31 | async function createAudioSampleAsset (createSampleFunction) { 32 | const array = createSampleFunction() 33 | const result = TheAudioContext.createBuffer(1, array.length, TheAudioContext.sampleRate) 34 | result.getChannelData(0).set(array) 35 | 36 | await waitForNextFrame() 37 | 38 | return result 39 | } 40 | 41 | async function createReverb () { 42 | const reverb = TheAudioContext.createConvolver() 43 | const ir = createReverbIR() 44 | const irBuffer = TheAudioContext.createBuffer(2, ir[0].length, TheAudioContext.sampleRate) 45 | irBuffer.getChannelData(0).set(ir[0]) 46 | irBuffer.getChannelData(1).set(ir[1]) 47 | 48 | reverb.buffer = irBuffer 49 | 50 | setReverbDestination(reverb) 51 | 52 | await waitForNextFrame() 53 | } 54 | 55 | function augmentWithImage (spriteObject) { 56 | return new Promise((resolve) => { 57 | const img = new Image() 58 | img.onload = () => { 59 | spriteObject.renderable = img 60 | resolve(spriteObject) 61 | } 62 | img.src = spriteObject.dataUrl 63 | }) 64 | } 65 | 66 | export async function loadAssets () { 67 | ;[ 68 | FlowersSprite, 69 | Font, 70 | ParticlesSprite, 71 | PlayerSprite, 72 | WingSprite 73 | ] = await Promise.all( 74 | [ 75 | _FlowersSprite, 76 | _Font, 77 | _ParticlesSprite, 78 | _PlayerSprite, 79 | _WingSprite 80 | ].map(augmentWithImage) 81 | ) 82 | 83 | await waitForNextFrame() 84 | 85 | ;[ 86 | DashSound, 87 | ImpactSound, 88 | JumpSound, 89 | DeathSound, 90 | RebootSound 91 | ] = await Promise.all( 92 | [ 93 | createDashSound, 94 | createImpactSound, 95 | createJumpSound, 96 | createDeathSound, 97 | createRebootSound 98 | ].map(createAudioSampleAsset) 99 | ) 100 | 101 | createReverb() 102 | 103 | Song1 = await createSong1() 104 | 105 | document.body.classList.remove('loading') 106 | } 107 | -------------------------------------------------------------------------------- /src/Entities/Goal.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from '../constants' 2 | import { ThePlayer, deltaTime } from '../globals' 3 | import { TheGraphics } from '../Graphics' 4 | import { TheRenderer } from '../Renderer' 5 | import { approach, distanceSquared } from '../utils' 6 | import { GridEntity } from './GridEntity' 7 | 8 | let index = 0 9 | let colors = ['#f00', '#fff', '#0f0', '#00f', '#0ff', '#ff0', '#f0f'] 10 | 11 | class Point { 12 | constructor (goalX, goalY) { 13 | this.goalX = goalX 14 | this.goalY = goalY 15 | 16 | let r = 5 + Math.random() * 10 17 | let rSpeed = 10 18 | let a = Math.random() * 2 * Math.PI 19 | this.x = this.goalX + Math.cos(a) * r 20 | this.y = this.goalY + Math.sin(a) * r 21 | this.xSpeed = Math.sin(a) * rSpeed 22 | this.ySpeed = -Math.cos(a) * rSpeed 23 | this.gravity = 500 24 | 25 | this.color = colors[index++ % colors.length] 26 | } 27 | 28 | step () { 29 | this.x += this.xSpeed * deltaTime 30 | this.y += this.ySpeed * deltaTime 31 | 32 | this.gravitateTowards(this.gravity, this.goalX, this.goalY) 33 | 34 | if (ThePlayer.finishedLevel) { 35 | this.gravity = approach(this.gravity, 100, 100) 36 | } 37 | } 38 | 39 | gravitateTowards (gravity, x, y) { 40 | let dx = this.x - x 41 | let dy = this.y - y 42 | let rr = Math.max(1, dx * dx + dy * dy) 43 | let ax = -gravity * dx / rr 44 | let ay = -gravity * dy / rr 45 | 46 | this.xSpeed += ax * deltaTime 47 | this.ySpeed += ay * deltaTime 48 | } 49 | } 50 | 51 | const NUM_POINTS = 20 52 | const GOAL_RADIUS = TILE_SIZE * 2 53 | 54 | export class Goal extends GridEntity { 55 | constructor (x, y) { 56 | super(x, y) 57 | 58 | this.points = [] 59 | for (let i = 0; i < NUM_POINTS; i++) { 60 | this.points.push(new Point( 61 | this.x + TILE_SIZE / 2, 62 | this.y + TILE_SIZE / 2 63 | )) 64 | } 65 | 66 | this.alpha = 1 67 | } 68 | 69 | step () { 70 | let bbox = ThePlayer.boundingBox 71 | let playerX = bbox.centerX 72 | let playerY = bbox.centerY 73 | let centerX = this.x + TILE_SIZE / 2 74 | let centerY = this.y + TILE_SIZE / 2 75 | 76 | if (distanceSquared(centerX, centerY, playerX, playerY) < GOAL_RADIUS * GOAL_RADIUS && (ThePlayer.isDashing || ThePlayer.ySpeed >= 260)) { 77 | ThePlayer.setFinished() 78 | } 79 | 80 | if (ThePlayer.finishedLevel) { 81 | this.alpha = approach(this.alpha, 0, deltaTime) 82 | } 83 | } 84 | 85 | render () { 86 | TheRenderer.setAlpha(this.alpha) 87 | 88 | for (let i = 0; i < NUM_POINTS; i++) { 89 | let point = this.points[i] 90 | 91 | let skip = Math.round(Math.random() * Math.random() * 8) 92 | for (let j = 0; j < skip; j++) { 93 | point.step() 94 | } 95 | 96 | TheRenderer.drawRectangle( 97 | point.color, 98 | point.x - 1, 99 | point.y - 1, 100 | 2, 101 | 2 102 | ) 103 | } 104 | TheRenderer.resetAlpha() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Camera.js: -------------------------------------------------------------------------------- 1 | import { TheCanvas } from './Graphics' 2 | import { TheWorld, ThePlayer, deltaTime } from './globals' 3 | import { approach } from './utils' 4 | 5 | const SHAKE_MULTIPLIER = 8 6 | const MAX_SHAKE_RADIUS = 6 7 | 8 | export class Camera { 9 | constructor () { 10 | this.x = 0 11 | this.y = 0 12 | this.targetX = 0 13 | this.targetY = 0 14 | this.shakeX = 0 15 | this.shakeY = 0 16 | this.shakeTargetX = 0 17 | this.shakeTargetY = 0 18 | this.shakeToggle = 0 19 | 20 | this.shakeAmount = 0 21 | this.shakeTime = 0 22 | 23 | this.scale = 2 24 | this.introZoomFactor = 1 25 | } 26 | 27 | step () { 28 | this.updateZoom() 29 | this.updateTarget() 30 | this.updateShake() 31 | 32 | this.x = this.targetX + this.shakeX 33 | this.y = this.targetY + this.shakeY 34 | } 35 | 36 | updateShake () { 37 | let r = Math.min(MAX_SHAKE_RADIUS, SHAKE_MULTIPLIER * this.shakeAmount * this.shakeAmount) 38 | let a = Math.random() * 2 * Math.PI 39 | 40 | if (this.shakeToggle) { 41 | this.shakeTargetX = r * Math.cos(a) 42 | this.shakeTargetY = r * Math.sin(a) 43 | } 44 | this.shakeToggle = !this.shakeToggle 45 | this.shakeX += (this.shakeTargetX - this.shakeX) * 0.75 46 | this.shakeY += (this.shakeTargetY - this.shakeY) * 0.75 47 | 48 | this.shakeAmount = approach(this.shakeAmount, 0, deltaTime) 49 | } 50 | 51 | updateTarget () { 52 | let playerCenterY = ThePlayer.boundingBox.centerY 53 | 54 | let zone1 = this.getHeight() / 10 55 | let zone2 = this.getHeight() / 6 56 | 57 | const approachY = (y) => { 58 | this.targetY += (playerCenterY + y - this.targetY) / 8 59 | } 60 | 61 | if (ThePlayer.grounded) { 62 | approachY(0) 63 | } 64 | else if (playerCenterY < this.y - zone2) { 65 | this.targetY = playerCenterY + zone2 66 | } 67 | else if (playerCenterY > this.y + zone2) { 68 | this.targetY = playerCenterY - zone2 69 | } 70 | else if (playerCenterY < this.y - zone1) { 71 | approachY(zone1) 72 | } 73 | else if (playerCenterY > this.y + zone1) { 74 | approachY(-zone1) 75 | } 76 | 77 | this.targetY = Math.max(0, Math.min(this.targetY, TheWorld.height)) 78 | this.targetX = Math.max(0, Math.min(ThePlayer.x, TheWorld.width)) 79 | } 80 | 81 | updateZoom () { 82 | this.introZoomFactor = approach(this.introZoomFactor, 0, deltaTime / 4) 83 | this.scale = 4 - 2 * this.introZoomFactor * this.introZoomFactor 84 | } 85 | 86 | addShake (amount) { 87 | this.shakeAmount = Math.min(1, this.shakeAmount + amount) 88 | this.shakeToggle = true 89 | } 90 | 91 | getWidth () { 92 | return TheCanvas.width / this.scale 93 | } 94 | 95 | getHeight () { 96 | return TheCanvas.height / this.scale 97 | } 98 | 99 | viewXToWorldX (x) { 100 | return (x - TheCanvas.width / 2) / this.scale + this.x 101 | } 102 | 103 | viewYToWorldY (y) { 104 | return (y - TheCanvas.height / 2) / this.scale + this.y 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Editor/LevelLoaderEditorPlayable.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, setColorScheme } from '../globals' 2 | import { LevelLoaderBase } from '../LevelLoaders/LevelLoaderBase' 3 | 4 | import { PlayerStart } from './Entities/PlayerStart' 5 | import { EditorSolidTile } from './Entities/EditorSolidTile' 6 | import { EditorHurtTile } from './Entities/EditorHurtTile' 7 | import { EditorMovingPlatform } from './Entities/EditorMovingPlatform' 8 | import { EditorGoal } from './Entities/EditorGoal' 9 | import { EditorText } from './Entities/EditorText' 10 | import { EditorCheckpoint } from './Entities/EditorCheckpoint' 11 | 12 | import { Player } from '../Entities/Player' 13 | import { SolidTile } from '../Entities/SolidTile' 14 | import { HurtTile } from '../Entities/HurtTile' 15 | import { MovingPlatform } from '../Entities/MovingPlatform' 16 | import { Goal } from '../Entities/Goal' 17 | import { InfoText } from '../Entities/InfoText' 18 | import { Checkpoint } from '../Entities/Checkpoint' 19 | import { BACKGROUND_LAYER, FOREGROUND_LAYER } from '../constants' 20 | import { MainTitle } from '../Entities/MainTitle' 21 | import { FinishAnimation } from '../Entities/FinishAnimation' 22 | import { levels } from '../Assets/levels' 23 | import { EditorFadeBlock } from './Entities/EditorFadeBlock' 24 | import { FadeBlock } from '../Entities/FadeBlock' 25 | 26 | export class LevelLoaderEditorPlayable extends LevelLoaderBase { 27 | constructor (editorWorld) { 28 | super() 29 | 30 | this.editorWorld = editorWorld 31 | } 32 | 33 | generate () { 34 | setColorScheme(this.editorWorld.levelNumber) 35 | 36 | let minX = Infinity 37 | let minY = Infinity 38 | 39 | this.editorWorld.forEachEntity(entity => { 40 | minX = Math.min(entity.x, minX) 41 | minY = Math.min(entity.y, minY) 42 | }) 43 | 44 | this.editorWorld.forEachEntity(entity => { 45 | let x = entity.x - minX 46 | let y = entity.y - minY 47 | if (entity instanceof PlayerStart) { 48 | TheWorld.setPlayer(new Player(x, y)) 49 | } 50 | if (entity instanceof EditorSolidTile) { 51 | TheWorld.addTile(new SolidTile(x, y)) 52 | } 53 | if (entity instanceof EditorHurtTile) { 54 | TheWorld.addTile(new HurtTile(x, y)) 55 | } 56 | if (entity instanceof EditorMovingPlatform) { 57 | TheWorld.addSolidEntity(new MovingPlatform(x, y, entity.width, entity.height, entity.xSpeed, entity.ySpeed)) 58 | } 59 | if (entity instanceof EditorFadeBlock) { 60 | TheWorld.addSolidEntity(new FadeBlock(x, y, entity.width, entity.height)) 61 | } 62 | if (entity instanceof EditorGoal) { 63 | TheWorld.addEntity(new Goal(x, y)) 64 | } 65 | if (entity instanceof EditorText) { 66 | TheWorld.addEntity(new InfoText(x, y, entity.text), BACKGROUND_LAYER) 67 | } 68 | if (entity instanceof EditorCheckpoint) { 69 | TheWorld.addEntity(new Checkpoint(x, y)) 70 | } 71 | }) 72 | 73 | if (this.editorWorld.levelNumber === 0) { 74 | TheWorld.addEntity(new MainTitle(), FOREGROUND_LAYER) 75 | } 76 | 77 | if (this.editorWorld.levelNumber === levels.length - 1) { 78 | TheWorld.addEntity(new FinishAnimation(true)) 79 | } 80 | 81 | TheWorld.updateDimensions() 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from './constants' 2 | import { frame, TheColorScheme } from './globals' 3 | 4 | /** 5 | * Some mathematical utilities 6 | */ 7 | 8 | export let sign = (x) => { 9 | if (x > 0) return 1 10 | if (x < 0) return -1 11 | return 0 12 | } 13 | 14 | export let clamp = (x, min, max) => { 15 | return Math.min(Math.max(x, min), max) 16 | } 17 | 18 | export let approach = (x, target, acc) => { 19 | return x > target ? Math.max(x - acc, target) : Math.min(x + acc, target) 20 | } 21 | 22 | export function overlapping (rect1, rect2) { 23 | return ( 24 | rect1.x + rect1.width > rect2.x && rect1.x < rect2.x + rect2.width && 25 | rect1.y + rect1.height > rect2.y && rect1.y < rect2.y + rect2.height 26 | ) 27 | } 28 | 29 | export function manhattanDistance (x1, y1, x2, y2) { 30 | return Math.abs(x1 - x2) + Math.abs(y1 - y2) 31 | } 32 | 33 | export function distanceSquared(x1, y1, x2, y2) { 34 | let dx = Math.abs(x1 - x2) 35 | let dy = Math.abs(y1 - y2) 36 | return dx * dx + dy * dy 37 | } 38 | 39 | export function randomInt (upper) { 40 | return Math.floor(upper * Math.random()) 41 | } 42 | 43 | /** 44 | * Tiling utilities 45 | */ 46 | 47 | export let getCellX = x => Math.floor(x / TILE_SIZE) 48 | export let getCellY = getCellX 49 | 50 | export let forRectangularRegion = (x0, y0, x1, y1, callback) => { 51 | for (let yi = y0; yi <= y1; yi++) { 52 | for (let xi = x0; xi <= x1; xi++) { 53 | callback(xi, yi) 54 | } 55 | } 56 | } 57 | 58 | /** 59 | * Entity system utilities 60 | */ 61 | 62 | export function hasTag (obj, tag) { 63 | return !!(obj.tags & tag) 64 | } 65 | 66 | /** 67 | * Image generation utilities 68 | */ 69 | 70 | export async function generateImage (width, height, callback) { 71 | let canvas = document.createElement('canvas') 72 | canvas.width = width 73 | canvas.height = height 74 | 75 | await callback(canvas.getContext('2d')) 76 | 77 | // We could technically return the canvas, but having lots of canvases is slower 78 | // than having lots of images. Sure the generation time goes up, but at least 79 | // the gameplay doesn't suffer as much. 80 | 81 | let blob = await new Promise(resolve => canvas.toBlob(resolve)) 82 | 83 | return new Promise(resolve => { 84 | let img = new Image() 85 | img.onload = () => resolve(img) 86 | img.src = URL.createObjectURL(blob) 87 | }) 88 | } 89 | 90 | export function renderSolidSquare (ctx, x, y) { 91 | ctx.fillStyle = makeColorWithAlpha(TheColorScheme.fg, 0.94 - 0.01 + Math.random() * 0.02) 92 | ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE) 93 | } 94 | 95 | /** 96 | * Color utilities 97 | */ 98 | 99 | export function makeColorWithAlpha (color, alpha) { 100 | let [_, type, args] = /^(\w+)\((.*)\)$/.exec(color) 101 | return `${type}(${args},${alpha})` 102 | } 103 | 104 | /** 105 | * Waiting for the next frame is useful for preventing the browser to hang 106 | * while the assets are being generated 107 | */ 108 | export async function waitForNextFrame () { 109 | await new Promise(resolve => requestAnimationFrame(resolve)) 110 | } 111 | 112 | /** 113 | * Debugging utilities 114 | */ 115 | 116 | export function debug (thing) { 117 | console.log(frame, thing) 118 | } 119 | -------------------------------------------------------------------------------- /src/Editor/SerializerUtils/RectangleCoverage.js: -------------------------------------------------------------------------------- 1 | import { 2 | approach, 3 | forRectangularRegion 4 | } from '../../utils' 5 | 6 | export class RectangleCoverage { 7 | constructor (width, height, map) { 8 | this.width = width 9 | this.height = height 10 | this.map = { ...map } 11 | 12 | this.result = [] 13 | } 14 | 15 | at (x, y) { 16 | return this.map[x + ';' + y] || 0 17 | } 18 | 19 | set (x, y, value) { 20 | this.map[x + ';' + y] = value 21 | } 22 | 23 | solve () { 24 | // pass 1 - only check rectangles at top-left corners 25 | for (let direction of [1, -1]) { 26 | this.setDirection(direction) 27 | 28 | let xStart = this.xEnd === -1 ? this.width - 1 : 0 29 | let yStart = this.yEnd === -1 ? this.height - 1 : 0 30 | 31 | for (let y = yStart; y != this.yEnd; y = approach(y, this.yEnd, 1)) { 32 | for (let x = xStart; x != this.xEnd; x = approach(x, this.xEnd, 1)) { 33 | if (this.at(x, y) === 1 && this.at(x - direction, y) === 0 && this.at(x, y - direction) === 0) { 34 | this.processLargestRectangleAt(x, y) 35 | } 36 | } 37 | } 38 | } 39 | 40 | // pass 2 - just check anything that's 1 41 | this.setDirection(1) 42 | for (let y = 0; y < this.height; y++) { 43 | for (let x = 0; x < this.width; x++) { 44 | if (this.at(x, y) === 1) { 45 | this.processLargestRectangleAt(x, y) 46 | } 47 | } 48 | } 49 | 50 | return this.result 51 | } 52 | 53 | setDirection (direction) { 54 | this.direction = direction 55 | 56 | this.xEnd = direction === 1 ? this.width : -1 57 | this.yEnd = direction === 1 ? this.height : -1 58 | } 59 | 60 | processLargestRectangleAt (x, y) { 61 | let biggestArea = 0 62 | let finalX0, finalX1, finalY0, finalY1 63 | for (let otherX = x; otherX != this.xEnd; otherX = approach(otherX, this.xEnd, 1)) { 64 | if (this.at(otherX, y) === 0) { 65 | break 66 | } 67 | 68 | let x0 = Math.min(otherX, x) 69 | let x1 = Math.max(otherX, x) 70 | 71 | let [y0, y1] = this.getBestY0Y1(x0, x1, y) 72 | let currentArea = this.getAreaOfUnprocessedCells(x0, y0, x1, y1) 73 | if (currentArea > biggestArea) { 74 | biggestArea = currentArea 75 | finalX0 = x0 76 | finalY0 = y0 77 | finalX1 = x1 78 | finalY1 = y1 79 | } 80 | } 81 | 82 | // Add the rectangle to the results 83 | this.result.push([ 84 | finalX0, 85 | finalY0, 86 | finalX1 - finalX0 + 1, 87 | finalY1 - finalY0 + 1 88 | ]) 89 | 90 | // Mark the rectangular region as processed 91 | forRectangularRegion( 92 | finalX0, 93 | finalY0, 94 | finalX1, 95 | finalY1, 96 | (xi, yi) => { 97 | this.set(xi, yi, 2) 98 | } 99 | ) 100 | } 101 | 102 | getBestY0Y1 (x0, x1, yStart) { 103 | let currentY0 = yStart 104 | let currentY1 = yStart 105 | 106 | for (let yi = yStart; yi != this.yEnd; yi = approach(yi, this.yEnd, 1)) { 107 | for (let xi = x0; xi <= x1; xi++) { 108 | if (this.at(xi, yi) === 0) 109 | return [currentY0, currentY1] 110 | } 111 | currentY0 = Math.min(yStart, yi) 112 | currentY1 = Math.max(yStart, yi) 113 | } 114 | 115 | return [currentY0, currentY1] 116 | } 117 | 118 | getAreaOfUnprocessedCells (x0, y0, x1, y1) { 119 | let result = 0 120 | forRectangularRegion(x0, y0, x1, y1, (xi, yi) => { 121 | if (this.at(xi, yi) === 1) { 122 | result++ 123 | } 124 | }) 125 | return result 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Audio/SongGeneration.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext, TheAudioDestination, TheReverbDestination } from './Context' 2 | import { waitForNextFrame } from '../utils' 3 | 4 | export function addSoundToBuffer (sourceData, targetData, offset) { 5 | if (!Array.isArray(sourceData)) { 6 | sourceData = [sourceData] 7 | } 8 | 9 | if (!Array.isArray(targetData)) { 10 | targetData = [targetData] 11 | } 12 | 13 | for (let i = 0; i < targetData.length; i++) { 14 | const sourceDataBuffer = sourceData[i % sourceData.length] 15 | const targetDataBuffer = targetData[i % targetData.length] 16 | 17 | const maxJ = Math.min(offset + sourceDataBuffer.length, targetDataBuffer.length) 18 | for (let j = offset; j < maxJ; j++) { 19 | targetDataBuffer[j] += sourceDataBuffer[j - offset] 20 | } 21 | } 22 | } 23 | 24 | export function addNotes (notes, output, instrument, bpm) { 25 | const bufferCache = {} 26 | notes.forEach(note => { 27 | let key = note.slice(1).join('|') 28 | if (!bufferCache[key]) { 29 | bufferCache[key] = instrument(getFrequencyForTone(note[1]), ...note.slice(2)) 30 | } 31 | addSoundToBuffer( 32 | bufferCache[key], 33 | output, 34 | getOffsetForBeat(note[0], bpm) 35 | ) 36 | }) 37 | } 38 | 39 | export function getOffsetForBeat (n, bpm) { 40 | return Math.round(TheAudioContext.sampleRate * n * 60 / bpm) 41 | } 42 | 43 | export function getFrequencyForTone (n) { 44 | return 440 * Math.pow(2, n / 12) 45 | } 46 | 47 | export function repeatNotes (x, length, repeat) { 48 | const result = [] 49 | for (let i = 0; i < repeat; i++) { 50 | x.forEach(([b, ...args]) => { 51 | result.push([b + length * i, ...args]) 52 | }) 53 | } 54 | return result 55 | } 56 | 57 | export function addOctave (notes) { 58 | for (let i = 0, l = notes.length; i < l; i++) { 59 | let [offset, note, ...rest] = notes[i] 60 | notes.push([offset, note + 12, ...rest]) 61 | } 62 | return notes 63 | } 64 | 65 | export function zipRhythmAndNotes (rhythm, notes) { 66 | return rhythm.map((beat, index) => { 67 | return [beat, notes[index]] 68 | }) 69 | } 70 | 71 | export function offsetNotes (notes, amount) { 72 | notes.forEach(note => { note[0] += amount }) 73 | return notes 74 | } 75 | 76 | export async function createChannel (trackFunction, sampleCount, bpm) { 77 | const channel = TheAudioContext.createBufferSource() 78 | channel.loop = true 79 | 80 | const buffer = TheAudioContext.createBuffer(1, sampleCount, TheAudioContext.sampleRate) 81 | trackFunction(buffer.getChannelData(0), bpm) 82 | 83 | channel.buffer = buffer 84 | 85 | await waitForNextFrame() 86 | 87 | return channel 88 | } 89 | 90 | export class Song { 91 | constructor (channels) { 92 | let master = TheAudioContext.createGain() 93 | 94 | this.channels = channels.map(channel => { 95 | let sourceNode = channel.source 96 | let gainNode = TheAudioContext.createGain() 97 | gainNode.gain.value = channel.volume 98 | 99 | sourceNode.connect(gainNode) 100 | gainNode.connect(master) 101 | 102 | if (channel.sendToReverb) { 103 | let gain = TheAudioContext.createGain() 104 | gain.gain.value = channel.sendToReverb 105 | gainNode.connect(gain) 106 | gain.connect(TheReverbDestination) 107 | } 108 | 109 | return { 110 | source: sourceNode, 111 | volumeParam: gainNode.gain 112 | } 113 | }) 114 | 115 | master.connect(TheAudioDestination) 116 | } 117 | 118 | setVolume (channel, volume, time = 1) { 119 | this.channels[channel].volumeParam.linearRampToValueAtTime(volume, TheAudioContext.currentTime + time) 120 | } 121 | 122 | play () { 123 | this.channels.forEach(channel => { 124 | channel.source.start() 125 | }) 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/Entities/MovingPlatform.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from '../constants' 2 | import { ThePlayer, TheWorld, deltaTime } from '../globals' 3 | import { overlapping, generateImage, forRectangularRegion, sign, renderSolidSquare } from '../utils' 4 | import { TheRenderer } from '../Renderer' 5 | import { GridEntity } from './GridEntity' 6 | 7 | export class MovingPlatform extends GridEntity { 8 | constructor (x, y, width, height, xSpeed, ySpeed) { 9 | super(x, y, width, height) 10 | 11 | this.startX = this.x 12 | this.startY = this.y 13 | 14 | this.startXSpeed = xSpeed 15 | this.startYSpeed = ySpeed 16 | 17 | this.reset() 18 | 19 | this.collidable = true 20 | } 21 | 22 | reset () { 23 | this.x = this.startX 24 | this.y = this.startY 25 | 26 | this.xSpeed = this.startXSpeed 27 | this.ySpeed = this.startYSpeed 28 | 29 | this.xRemainder = 0 30 | this.yRemainder = 0 31 | 32 | this.collidable = true 33 | } 34 | 35 | async initialize () { 36 | this.renderable = await generateImage(this.width, this.height, ctx => { 37 | forRectangularRegion(0, 0, this.width / TILE_SIZE - 1, this.height / TILE_SIZE - 1, (x, y) => { 38 | renderSolidSquare(ctx, x, y) 39 | }) 40 | }) 41 | } 42 | 43 | step () { 44 | this.collidable = false 45 | 46 | this.xRemainder += this.xSpeed * deltaTime 47 | this.yRemainder += this.ySpeed * deltaTime 48 | 49 | let dx = Math.round(this.xRemainder) 50 | let dy = Math.round(this.yRemainder) 51 | 52 | this.xRemainder -= dx 53 | this.yRemainder -= dy 54 | 55 | // Bouncing off of things 56 | let collider = this.collideAt(this.x + dx, this.y + dy) 57 | 58 | if (collider) { 59 | // Check if the other is moving on the same axis 60 | if ( 61 | (this.xSpeed && sign(collider.xSpeed) === -sign(this.xSpeed)) || 62 | (this.ySpeed && sign(collider.ySpeed) === -sign(this.ySpeed)) 63 | ) { 64 | collider.xSpeed *= -1 65 | collider.ySpeed *= -1 66 | } 67 | 68 | if (this.xSpeed > 0) { 69 | dx -= 2 * ((this.x + dx) % TILE_SIZE) 70 | } else if (this.xSpeed < 0) { 71 | dx -= 2 * ((this.x + dx) % TILE_SIZE - TILE_SIZE) 72 | } else if (this.ySpeed > 0) { 73 | dy -= 2 * ((this.y + dy) % TILE_SIZE) 74 | } else { 75 | dy -= 2 * ((this.y + dy) % TILE_SIZE - TILE_SIZE) 76 | } 77 | 78 | this.xSpeed *= -1 79 | this.ySpeed *= -1 80 | } 81 | 82 | let playerIsRiding = ThePlayer.isRiding(this) 83 | 84 | this.x += dx 85 | this.y += dy 86 | 87 | // Actually moving the player 88 | if (ThePlayer.isAlive) { 89 | if (playerIsRiding) { 90 | // If the player is dashing we don't have to move the player down when going down 91 | let newDy = dy < 0 || !ThePlayer.isDashing ? dy : 0 92 | ThePlayer.move(dx, newDy) 93 | } else { 94 | const overlappingPlayer = overlapping( 95 | ThePlayer.boundingBox, 96 | this 97 | ) 98 | 99 | if (overlappingPlayer) { 100 | if (dx) { 101 | let amount = dx > 0 102 | ? this.x + this.width - ThePlayer.boundingBox.x 103 | : this.x - ThePlayer.boundingBox.x - ThePlayer.boundingBox.width 104 | ThePlayer.moveX(amount, () => { 105 | ThePlayer.die() 106 | }) 107 | } else if (dy) { 108 | let amount = dy > 0 109 | ? this.y + this.height - ThePlayer.boundingBox.y 110 | : this.y - ThePlayer.boundingBox.y - ThePlayer.boundingBox.height 111 | ThePlayer.moveY(amount, () => { 112 | ThePlayer.die() 113 | }) 114 | } 115 | } 116 | } 117 | } 118 | 119 | this.collidable = true 120 | } 121 | 122 | collideAt (x, y) { 123 | return TheWorld.solidAt(x, y, this.width, this.height, { ignore: this }) 124 | } 125 | 126 | render () { 127 | TheRenderer.drawImage(this.renderable, this.x, this.y) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Editor/EditorWorld.js: -------------------------------------------------------------------------------- 1 | import { TheCamera } from '../globals' 2 | import { TheCanvas, TheGraphics } from '../Graphics' 3 | import { TheRenderer } from '../Renderer' 4 | import { getCellX, getCellY, forRectangularRegion } from '../utils' 5 | import { TILE_SIZE } from '../constants'; 6 | 7 | export class EditorWorld { 8 | constructor () { 9 | this.entityGrid = {} 10 | this.entities = new Set() 11 | } 12 | 13 | clear () { 14 | this.entityGrid = {} 15 | this.entities = new Set() 16 | } 17 | 18 | getEntityAt (x, y) { 19 | return this.entityGrid[x + ';' + y] 20 | } 21 | 22 | getExtremes () { 23 | let minX = Infinity 24 | let minY = Infinity 25 | let maxX = -Infinity 26 | let maxY = -Infinity 27 | 28 | for (let key in this.entityGrid) { 29 | let [x, y] = key.split(';').map(Number) 30 | minX = Math.min(minX, x) 31 | minY = Math.min(minY, y) 32 | maxX = Math.max(maxX, x) 33 | maxY = Math.max(maxY, y) 34 | } 35 | return { 36 | minX, maxX, minY, maxY 37 | } 38 | } 39 | 40 | addEntity (entity) { 41 | forRectangularRegion( 42 | entity.x, 43 | entity.y, 44 | entity.x + entity.width - 1, 45 | entity.y + entity.height - 1, 46 | (xi, yi) => { 47 | this.removeAt(xi, yi) 48 | 49 | this.entityGrid[xi + ';' + yi] = entity 50 | } 51 | ) 52 | 53 | this.entities.add(entity) 54 | } 55 | 56 | forEachEntity (callback) { 57 | for (let entity of this.entities) { 58 | callback(entity) 59 | } 60 | } 61 | 62 | removeAt (x, y) { 63 | let entity = this.entityGrid[x + ';' + y] 64 | if (!entity) { 65 | return 66 | } 67 | 68 | forRectangularRegion( 69 | entity.x, 70 | entity.y, 71 | entity.x + entity.width - 1, 72 | entity.y + entity.height - 1, 73 | (xi, yi) => { delete this.entityGrid[xi + ';' + yi] } 74 | ) 75 | 76 | this.entities.delete(entity) 77 | } 78 | 79 | setController (controller) { 80 | this.controller = controller 81 | } 82 | 83 | step () { 84 | this.controller.step() 85 | 86 | TheCamera.step() 87 | } 88 | 89 | render () { 90 | TheRenderer.clear() 91 | TheRenderer.drawRectangle('#888', 0, 0, TheCanvas.width, TheCanvas.height) 92 | 93 | TheRenderer.updateViewMatrix(100) 94 | 95 | let topIndex = getCellY(TheCamera.viewYToWorldY(0)) 96 | let bottomIndex = getCellY(TheCamera.viewYToWorldY(TheCanvas.height - 1)) 97 | let leftIndex = getCellX(TheCamera.viewXToWorldX(0)) 98 | let rightIndex = getCellX(TheCamera.viewXToWorldX(TheCanvas.width - 1)) 99 | 100 | this.renderGrid(topIndex, bottomIndex, leftIndex, rightIndex) 101 | 102 | forRectangularRegion(leftIndex, topIndex, rightIndex, bottomIndex, (x, y) => { 103 | let entity = this.entityGrid[x + ';' + y] 104 | if (entity) { 105 | entity.render() 106 | } 107 | }) 108 | 109 | this.controller.render() 110 | } 111 | 112 | renderGrid (topIndex, bottomIndex, leftIndex, rightIndex) { 113 | TheGraphics.strokeStyle = '#777' 114 | TheGraphics.beginPath() 115 | for (let xi = leftIndex; xi <= rightIndex; xi++) { 116 | if (xi % 8 === 0) { 117 | TheGraphics.stroke() 118 | TheGraphics.strokeStyle = '#666' 119 | TheGraphics.beginPath() 120 | } 121 | TheGraphics.moveTo(xi * TILE_SIZE, topIndex * TILE_SIZE) 122 | TheGraphics.lineTo(xi * TILE_SIZE, (bottomIndex + 1) * TILE_SIZE) 123 | if (xi % 8 === 0) { 124 | TheGraphics.stroke() 125 | TheGraphics.strokeStyle = '#777' 126 | TheGraphics.beginPath() 127 | } 128 | } 129 | for (let yi = topIndex; yi <= bottomIndex; yi++) { 130 | if (yi % 8 === 0) { 131 | TheGraphics.stroke() 132 | TheGraphics.strokeStyle = '#666' 133 | TheGraphics.beginPath() 134 | } 135 | TheGraphics.moveTo(leftIndex * TILE_SIZE, yi * TILE_SIZE) 136 | TheGraphics.lineTo((rightIndex + 1) * TILE_SIZE, yi * TILE_SIZE) 137 | if (yi % 8 === 0) { 138 | TheGraphics.stroke() 139 | TheGraphics.strokeStyle = '#777' 140 | TheGraphics.beginPath() 141 | } 142 | } 143 | TheGraphics.stroke() 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /assets/levels.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"maps":{"1":[[0,0,56,50],[126,56,23,34],[149,56,25,22],[149,63,33,15],[182,63,16,11],[198,60,8,14],[206,57,11,17],[217,62,13,12],[222,74,28,24],[250,66,9,32],[270,74,16,16],[286,82,16,8],[294,90,48,8],[30,50,96,10],[301,15,25,43],[318,58,8,28],[318,74,16,12],[334,63,8,7],[340,15,12,46],[340,15,32,40],[342,64,8,6],[350,63,11,7],[361,58,23,12],[374,67,66,31],[440,67,22,7],[446,15,48,33],[462,58,8,16],[55,45,27,25],[92,60,34,4]],"2":[[230,73,20,1],[342,63,8,1],[352,55,20,1],[384,66,30,1]]},"entities":[[3,59,44],[4,246,66,4,4,-60,0],[5,466,53],[6,157,46,"WHAT IS THE CAUSE OF THIS^"],[6,180,59,"PRESS SPACE TO JUMP"],[6,233,50,"AND WHERE DID THESE OBSTACLES COME FROM^"],[6,295,78,"HOLD SHIFT TO SUMMON WINGS"],[6,344,82,"USING YOUR WINGS] PRESS#SPACE TO DASH"],[6,451,57,"LOOK AT THAT["],[6,470,54,"DASH THROUGH IT TO RELEASE#THE ESSENCE OF THIS PARADISE["],[6,86,40,"PARADISE HAS BEEN TURNED OFFLINE\\\\\\"],[7,225,61],[7,337,62],[7,337,89],[8,334,74,8,5]]}, 3 | {"maps":{"1":[[0,0,54,50],[106,49,6,52],[112,0,22,46],[118,49,6,52],[130,59,38,42],[136,50,8,51],[149,45,13,4],[153,0,38,37],[153,0,42,34],[168,45,11,56],[179,42,3,25],[179,79,13,22],[182,45,14,22],[196,39,3,28],[199,36,6,31],[205,47,14,20],[212,67,7,14],[213,0,11,38],[216,42,27,10],[219,75,7,15],[226,63,6,6],[226,80,6,6],[226,95,6,6],[232,69,7,6],[232,87,7,3],[239,58,34,22],[273,0,17,80],[37,50,17,16],[54,42,10,24],[60,0,74,24],[64,47,16,19],[80,34,8,67],[88,24,24,6],[88,55,42,46],[88,95,124,6],[94,38,7,7]],"2":[[101,38,1,7],[111,30,1,16],[112,46,22,1],[112,54,6,1],[124,54,6,1],[130,58,6,1],[144,58,24,1],[148,45,1,4],[149,44,13,1],[149,49,13,1],[153,37,38,1],[162,45,1,4],[178,42,1,3],[179,41,3,1],[182,42,1,3],[191,34,1,3],[191,34,4,1],[192,67,20,1],[192,79,1,16],[195,39,1,6],[196,38,3,1],[205,46,11,1],[213,38,11,1],[219,52,24,1],[219,74,7,1],[219,90,7,1],[224,0,1,38],[232,68,7,1],[232,86,7,1],[232,90,7,1],[239,57,33,1],[272,0,1,58],[60,24,28,1],[64,46,16,1],[88,30,24,1],[88,54,18,1],[93,38,1,7],[94,37,7,1],[94,45,7,1]]},"entities":[[3,56,41],[5,182,75],[7,139,49],[7,234,41],[8,243,38,29,8]]}, 4 | {"maps":{"1":[[0,1,50,50],[112,1,8,43],[112,57,8,31],[120,66,10,22],[130,75,62,13],[134,1,71,58],[192,66,21,22],[205,88,8,9],[213,70,13,27],[226,77,93,20],[237,64,5,2],[257,1,4,65],[286,54,13,12],[309,1,6,61],[319,61,9,5],[319,71,9,26],[328,92,23,5],[337,1,14,86],[337,1,52,85],[344,97,7,21],[351,106,47,12],[365,1,3,91],[389,1,12,15],[395,64,15,22],[398,92,6,26],[404,105,19,13],[408,1,41,15],[408,23,41,4],[415,57,34,20],[423,27,2,91],[425,1,24,117],[50,44,8,35],[56,1,32,32],[58,50,9,29],[67,59,13,20],[73,33,7,16],[80,1,8,53],[80,60,8,19],[88,67,24,12]],"2":[[112,56,8,1],[226,76,93,1],[237,63,5,1],[286,53,13,1],[319,60,9,1],[319,70,9,1],[328,61,1,5],[328,71,1,21],[336,1,1,86],[337,0,64,1],[351,105,47,1],[351,86,14,1],[368,86,21,1],[389,16,12,1],[395,63,15,1],[395,86,15,1],[398,91,6,1],[408,0,41,1],[408,16,8,1],[408,22,8,1],[408,27,15,1],[58,49,9,1],[72,33,1,16],[80,59,8,1],[88,66,24,1]]},"entities":[[3,52,43],[4,112,49,8,3,0,100],[4,139,65,5,7,0,120],[4,144,64,5,7,0,120],[4,149,63,5,7,0,120],[4,154,62,5,7,0,120],[4,159,61,5,7,0,120],[4,164,60,5,7,0,120],[4,175,64,14,10,0,100],[4,215,66,4,4,100,0],[4,351,92,14,5,0,100],[4,368,92,30,5,0,100],[4,400,55,5,6,0,-200],[4,404,92,19,5,0,100],[4,419,77,4,4,-120,0],[4,58,44,5,5,60,0],[4,72,49,5,5,60,0],[4,89,60,7,3,60,0],[5,421,19],[7,123,65],[7,208,65],[7,339,91],[8,222,66,4,4]]}, 5 | {"maps":{"1":[[0,0,26,105],[0,0,41,67],[110,44,16,17],[134,0,95,56],[134,56,15,7],[134,70,8,19],[142,81,43,8],[149,0,26,66],[185,70,17,19],[208,0,8,70],[216,0,13,59],[224,69,5,6],[239,61,5,7],[257,61,13,7],[283,69,5,6],[294,0,8,72],[308,70,18,4],[31,79,15,4],[31,88,7,17],[38,73,24,5],[38,78,8,5],[38,97,32,8],[41,61,6,6],[47,0,33,43],[47,43,15,12],[54,55,8,23],[54,84,4,6],[69,60,5,11],[69,76,12,12]],"2":[[134,63,15,1],[134,69,8,1],[142,80,43,1],[185,69,9,1],[223,69,1,6],[224,68,5,1],[224,75,5,1],[229,69,1,6],[238,61,1,7],[239,60,5,1],[239,68,5,1],[244,61,1,7],[256,61,1,7],[257,60,13,1],[257,68,13,1],[26,67,1,38],[27,67,20,1],[270,61,1,7],[282,69,1,6],[283,68,5,1],[283,75,5,1],[288,69,1,6],[31,78,7,1],[31,83,15,1],[38,72,16,1],[46,96,24,1],[47,55,7,1],[53,84,1,6],[54,83,4,1],[54,90,4,1],[58,84,1,6],[62,43,18,1],[68,76,1,12],[69,59,5,1],[69,71,5,1],[69,75,12,1],[69,88,12,1],[81,76,1,12]]},"entities":[[3,43,60],[4,149,66,3,4,60,0],[4,172,66,3,4,60,0],[4,255,69,7,4,-80,0],[5,322,67],[7,112,43],[7,197,69],[7,41,96],[8,126,57,8,4],[8,142,70,3,3],[8,152,70,3,3],[8,162,70,3,3],[8,172,70,3,3],[8,182,70,3,3],[8,202,76,100,4],[8,31,73,7,5],[8,47,61,7,3],[8,47,92,3,3],[8,62,89,3,3],[8,64,58,3,3],[8,77,47,3,3],[8,77,68,3,3],[8,78,96,3,3],[8,88,83,3,3],[8,94,42,3,3]]}, 6 | {"maps":{"1":[[0,0,46,45],[103,65,14,10],[118,52,10,8],[128,44,11,16],[131,57,20,12],[46,31,19,14],[47,69,14,11],[61,61,20,12],[70,71,34,15],[93,49,14,9]],"2":[]},"entities":[[3,90,70],[6,84,64,"THANKS FOR PLAYING["]]} 7 | ] 8 | -------------------------------------------------------------------------------- /src/Editor/EditorCamera.js: -------------------------------------------------------------------------------- 1 | import { TILE_SIZE } from '../constants' 2 | import { TheCamera } from '../globals' 3 | import { Camera } from '../Camera' 4 | import { TheCanvas } from '../Graphics' 5 | import { getCellX, getCellY, clamp } from '../utils' 6 | 7 | export class EditorCamera extends Camera { 8 | constructor () { 9 | super() 10 | 11 | document.addEventListener('mousedown', this.onMouseDown.bind(this), false) 12 | document.addEventListener('mousemove', this.onMouseMove.bind(this), false) 13 | document.addEventListener('mouseup', this.onMouseUp.bind(this), false) 14 | document.addEventListener('contextmenu', e => e.preventDefault(), false) 15 | 16 | this.pause = false 17 | window.addEventListener('blur', e => this.pause = true, false) 18 | window.addEventListener('focus', e => this.pause = false, false) 19 | 20 | this.mouse = { 21 | leftDown: false, 22 | rightDown: false, 23 | screenX: TheCanvas.width / 2, 24 | screenY: TheCanvas.height / 2, 25 | worldX: 0, 26 | worldY: 0, 27 | tileX: 0, 28 | tileY: 0 29 | } 30 | 31 | this.listeners = [] 32 | } 33 | 34 | addMouseListener (obj) { 35 | this.listeners.push(obj) 36 | } 37 | 38 | updateMousePosition (e) { 39 | let scale 40 | if (window.innerWidth / window.innerHeight > TheCanvas.width / TheCanvas.height) { 41 | scale = window.innerHeight / TheCanvas.height 42 | } else { 43 | scale = window.innerWidth / TheCanvas.width 44 | } 45 | let actualWidth = TheCanvas.width * scale 46 | let actualHeight = TheCanvas.height * scale 47 | let leftSide = window.innerWidth / 2 - actualWidth / 2 48 | let topSide = window.innerHeight / 2 - actualHeight / 2 49 | this.mouse.screenX = (e.clientX - leftSide) / scale 50 | this.mouse.screenY = (e.clientY - topSide) / scale 51 | 52 | this.updateDerivedPositions() 53 | } 54 | 55 | updateDerivedPositions () { 56 | this.mouse.worldX = this.viewXToWorldX(this.mouse.screenX) 57 | this.mouse.worldY = this.viewYToWorldY(this.mouse.screenY) 58 | 59 | this.mouse.tileX = getCellX(this.mouse.worldX) 60 | this.mouse.tileY = getCellY(this.mouse.worldY) 61 | } 62 | 63 | onMouseDown (e) { 64 | if (TheCamera !== this) { 65 | return 66 | } 67 | 68 | e.preventDefault() 69 | this.updateMousePosition(e) 70 | if (e.which === 1) this.mouse.leftDown = true 71 | if (e.which === 3) this.mouse.rightDown = true 72 | 73 | this.listeners.forEach(listener => listener.handleMouseDown({ ...this.mouse })) 74 | } 75 | 76 | onMouseMove (e) { 77 | if (TheCamera !== this) { 78 | return 79 | } 80 | 81 | e.preventDefault() 82 | this.updateMousePosition(e) 83 | 84 | this.listeners.forEach(listener => listener.handleMouseMove({ ...this.mouse })) 85 | } 86 | 87 | onMouseUp (e) { 88 | if (TheCamera !== this) { 89 | return 90 | } 91 | 92 | e.preventDefault() 93 | this.updateMousePosition(e) 94 | if (e.which === 1) this.mouse.leftDown = false 95 | if (e.which === 3) this.mouse.rightDown = false 96 | 97 | this.listeners.forEach(listener => listener.handleMouseUp({ ...this.mouse })) 98 | } 99 | 100 | updateTarget () { 101 | if (this.pause) { 102 | return 103 | } 104 | 105 | let margin = 200 106 | let maxSpeed = 5 107 | let updatedPosition = false 108 | if (this.mouse.screenX < margin) { 109 | this.targetX -= (margin - this.mouse.screenX) / margin * maxSpeed 110 | updatedPosition = true 111 | } 112 | if (this.mouse.screenY < margin) { 113 | this.targetY -= (margin - this.mouse.screenY) / margin * maxSpeed 114 | updatedPosition = true 115 | } 116 | if (this.mouse.screenX > TheCanvas.width - margin) { 117 | this.targetX += (this.mouse.screenX - (TheCanvas.width - margin)) / margin * maxSpeed 118 | updatedPosition = true 119 | } 120 | if (this.mouse.screenY > TheCanvas.height - margin) { 121 | this.targetY += (this.mouse.screenY - (TheCanvas.height - margin)) / margin * maxSpeed 122 | updatedPosition = true 123 | } 124 | 125 | if (this.targetX < this.minX) { 126 | this.targetX += (this.minX - this.targetX) / 12 127 | } 128 | if (this.targetY < this.minY) { 129 | this.targetY += (this.minY - this.targetY) / 12 130 | } 131 | if (this.targetX > this.maxX) { 132 | this.targetX += (this.maxX - this.targetX) / 12 133 | } 134 | if (this.targetY > this.maxY) { 135 | this.targetY += (this.maxY - this.targetY) / 12 136 | } 137 | 138 | if (updatedPosition) { 139 | this.updateDerivedPositions() 140 | this.listeners.forEach(listener => listener.handleMouseMove({ ...this.mouse })) 141 | } 142 | } 143 | 144 | setBoundaries ({ minX, maxX, minY, maxY }) { 145 | this.minX = minX * TILE_SIZE 146 | this.maxX = maxX * TILE_SIZE 147 | this.minY = minY * TILE_SIZE 148 | this.maxY = maxY * TILE_SIZE 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Audio/SoundGeneration.js: -------------------------------------------------------------------------------- 1 | import { TheAudioContext } from './Context' 2 | 3 | 4 | export function sampleSine (position) { 5 | return Math.sin(2 * Math.PI * position) 6 | } 7 | 8 | export function sampleSawtooth (position) { 9 | return (position % 1) * 2 - 1 10 | } 11 | 12 | export function sampleTriangle (position) { 13 | return Math.abs((position % 1) * 2 - 1) * 2 - 1 14 | } 15 | 16 | export function sampleSquare (position) { 17 | return samplePulse(position, 0.5) 18 | } 19 | 20 | export function samplePulse (position, length) { 21 | return (position % 1 < length) * 2 - 1 22 | } 23 | 24 | export function sampleNoise () { 25 | return Math.random() * 2 - 1 26 | } 27 | 28 | export function sampleEnvelope (position, envelope) { 29 | for (let i = 0; i < envelope.length - 1; i++) { 30 | let [t1, v1, curve = 1] = envelope[i] 31 | let [t2, v2] = envelope[i + 1] 32 | if (t1 <= position && position < t2) { 33 | let t = (position - t1) / (t2 - t1) 34 | if (curve > 1) { 35 | t = Math.pow(t, curve) 36 | } else { 37 | t = 1 - Math.pow((1 - t), 1 / curve) 38 | } 39 | return v1 + t * (v2 - v1) 40 | } 41 | } 42 | return envelope[envelope.length - 1][1] 43 | } 44 | 45 | function ensureEnvelope (envelopeOrValue) { 46 | if (typeof envelopeOrValue === 'number') { 47 | return [[0, envelopeOrValue], [1, envelopeOrValue]] 48 | } 49 | return envelopeOrValue 50 | } 51 | 52 | function coefficients (b0, b1, b2, a0, a1, a2) { 53 | return [ 54 | b0 / a0, 55 | b1 / a0, 56 | b2 / a0, 57 | a1 / a0, 58 | a2 / a0 59 | ] 60 | } 61 | 62 | function getHighPassCoefficients (frequency, Q) { 63 | let n = Math.tan(Math.PI * frequency / TheAudioContext.sampleRate) 64 | let nSquared = n * n 65 | let invQ = 1 / Q 66 | let c1 = 1 / (1 + invQ * n + nSquared) 67 | 68 | return coefficients( 69 | c1, c1 * -2, 70 | c1, 1, 71 | c1 * 2 * (nSquared - 1), 72 | c1 * (1 - invQ * n + nSquared) 73 | ) 74 | } 75 | 76 | function getLowPassCoefficients (frequency, Q) { 77 | let n = 1 / Math.tan(Math.PI * frequency / TheAudioContext.sampleRate) 78 | let nSquared = n * n 79 | let invQ = 1 / Q 80 | let c1 = 1 / (1 + invQ * n + nSquared) 81 | 82 | return coefficients( 83 | c1, c1 * 2, 84 | c1, 1, 85 | c1 * 2 * (1 - nSquared), 86 | c1 * (1 - invQ * n + nSquared) 87 | ) 88 | } 89 | 90 | function getBandPassCoefficients (frequency, Q) { 91 | let n = 1 / Math.tan(Math.PI * frequency / TheAudioContext.sampleRate) 92 | let nSquared = n * n 93 | let invQ = 1 / Q 94 | let c1 = 1 / (1 + invQ * n + nSquared) 95 | 96 | return coefficients( 97 | c1 * n * invQ, 0, 98 | -c1 * n * invQ, 1, 99 | c1 * 2 * (1 - nSquared), 100 | c1 * (1 - invQ * n + nSquared) 101 | ) 102 | } 103 | 104 | function filter (buffer, coeffFunction, frequencies, Qs) { 105 | let lv1 = 0 106 | let lv2 = 0 107 | 108 | for (let i = 0; i < buffer.length; ++i) { 109 | let freq = sampleEnvelope(i / (buffer.length - 1), frequencies) 110 | let Q = sampleEnvelope(i / (buffer.length - 1), Qs) 111 | let coeffs = coeffFunction(freq, Q) 112 | 113 | let inV = buffer[i] 114 | let outV = (inV * coeffs[0]) + lv1 115 | buffer[i] = outV 116 | 117 | lv1 = (inV * coeffs[1]) - (outV * coeffs[3]) + lv2 118 | lv2 = (inV * coeffs[2]) - (outV * coeffs[4]) 119 | } 120 | 121 | return buffer 122 | } 123 | 124 | export function lowPassFilter (buffer, frequencies, Q = Math.SQRT1_2) { 125 | return filter(buffer, getLowPassCoefficients, ensureEnvelope(frequencies), ensureEnvelope(Q)) 126 | } 127 | 128 | export function highPassFilter (buffer, frequencies, Q = Math.SQRT1_2) { 129 | return filter(buffer, getHighPassCoefficients, ensureEnvelope(frequencies), ensureEnvelope(Q)) 130 | } 131 | 132 | export function bandPassFilter (buffer, frequencies, Q = Math.SQRT1_2) { 133 | return filter(buffer, getBandPassCoefficients, ensureEnvelope(frequencies), ensureEnvelope(Q)) 134 | } 135 | 136 | export function distort (buffer, amount) { 137 | for (let i = 0; i < buffer.length; i++) { 138 | buffer[i] *= amount 139 | if (buffer[i] < -1) buffer[i] = -1 140 | else if (buffer[i] > 1) buffer[i] = 1 141 | else buffer[i] = Math.sin(buffer[i] * Math.PI / 2) 142 | buffer[i] /= amount 143 | } 144 | return buffer 145 | } 146 | 147 | function combineSounds (buffers, func) { 148 | let maxLength = 0 149 | buffers.forEach(buffer => { maxLength = Math.max(maxLength, buffer.length) }) 150 | 151 | const outputBuffer = new Float32Array(maxLength) 152 | 153 | buffers.forEach((buffer, j) => { 154 | for (let i = 0; i < buffer.length; i++) { 155 | func(outputBuffer, j, buffer, i, buffers.length) 156 | } 157 | }) 158 | 159 | return outputBuffer 160 | } 161 | 162 | export function sumSounds (buffers) { 163 | return combineSounds(buffers, (data, bufferIndex, bufferData, sampleIndex, bufferCount) => { 164 | data[sampleIndex] += bufferData[sampleIndex] / bufferCount 165 | }) 166 | } 167 | 168 | export function multiplySounds (buffers) { 169 | return combineSounds(buffers, (data, bufferIndex, bufferData, sampleIndex, bufferCount) => { 170 | if (bufferIndex === 0) { 171 | data[sampleIndex] = 1 172 | } 173 | data[sampleIndex] *= bufferData[sampleIndex] / bufferCount 174 | }) 175 | } 176 | 177 | export function generateSound (length, sampleFunction) { 178 | const buffer = new Float32Array(length * TheAudioContext.sampleRate) 179 | 180 | for (let i = 0; i < buffer.length; i++) { 181 | buffer[i] = sampleFunction(i / buffer.length, i / TheAudioContext.sampleRate) 182 | } 183 | 184 | return buffer 185 | } 186 | 187 | export function applyEnvelope (buffer, envelope) { 188 | for (let i = 0; i < buffer.length; i++) { 189 | buffer[i] *= sampleEnvelope(i / buffer.length, envelope) 190 | } 191 | 192 | return buffer 193 | } 194 | 195 | export function getFrequencyDelta (freq) { 196 | return freq / TheAudioContext.sampleRate 197 | } 198 | -------------------------------------------------------------------------------- /src/World.js: -------------------------------------------------------------------------------- 1 | import { TAG_IS_SOLID, TAG_IS_DEATH, TAG_IS_COLLECTIBLE, BACKGROUND_LAYER, MAIN_LAYER, FOREGROUND_LAYER } from './constants' 2 | import { ThePlayer, setThePlayer, TheCamera } from './globals' 3 | import { overlapping, getCellX, getCellY } from './utils' 4 | 5 | import { Player } from './Entities/Player' 6 | import { Tileset } from './Entities/Tileset' 7 | import { Fade } from './Entities/Fade' 8 | 9 | import { TheGraphics } from './Graphics' 10 | import { TheRenderer } from './Renderer' 11 | import { TileRenderer } from './Renderers/TileRenderer' 12 | import { BackgroundRenderer } from './Renderers/BackgroundRenderer' 13 | 14 | export class World { 15 | constructor () { 16 | this.tiles = new Tileset() 17 | this.solidEntities = new Set() 18 | 19 | this.layers = { 20 | [BACKGROUND_LAYER]: new Set(), 21 | [MAIN_LAYER]: new Set(), 22 | [FOREGROUND_LAYER]: new Set() 23 | } 24 | 25 | this.guiEntities = new Set() 26 | 27 | this.entities = new Set() 28 | 29 | this.tileRenderer = new TileRenderer(this.tiles) 30 | this.bgRenderer = new BackgroundRenderer() 31 | 32 | this.addGuiEntity(new Fade('#fff', 1)) 33 | } 34 | 35 | async initEntities () { 36 | await this.bgRenderer.prerender() 37 | await this.tileRenderer.prerender() 38 | 39 | for (let entity of this.entities) { 40 | if (entity.initialize) { 41 | await entity.initialize() 42 | } 43 | } 44 | } 45 | 46 | updateDimensions () { 47 | this.width = 0 48 | this.height = 0 49 | this.tiles.forEachTile(tile => { 50 | this.width = Math.max(this.width, tile.x * 8) 51 | this.height = Math.max(this.height, tile.y * 8) 52 | }) 53 | } 54 | 55 | setPlayer (player) { 56 | setThePlayer(player) 57 | 58 | this.playerSpawnX = player.startX 59 | this.playerSpawnY = player.startY 60 | } 61 | 62 | respawnPlayer () { 63 | for (let entity of this.entities) { 64 | if (entity.reset) { 65 | entity.reset() 66 | } 67 | } 68 | setThePlayer(new Player(this.playerSpawnX, this.playerSpawnY)) 69 | } 70 | 71 | /** 72 | * Entities 73 | */ 74 | addEntity (entity, layer = MAIN_LAYER) { 75 | this.layers[layer].add(entity) 76 | this.entities.add(entity) 77 | entity.__layer = layer 78 | } 79 | 80 | removeEntity (entity) { 81 | this.entities.delete(entity) 82 | this.layers[entity.__layer].delete(entity) 83 | } 84 | 85 | /** 86 | * Solids 87 | */ 88 | addSolidEntity (entity) { 89 | this.solidEntities.add(entity) 90 | this.addEntity(entity) 91 | } 92 | 93 | removeSolidEntity (entity) { 94 | this.solidEntities.delete(entity) 95 | this.removeEntity(entity) 96 | } 97 | 98 | /** 99 | * Tiles 100 | */ 101 | addTile (tile) { 102 | this.tiles.addTile(tile) 103 | } 104 | 105 | /** 106 | * GUI 107 | */ 108 | addGuiEntity (entity) { 109 | this.guiEntities.add(entity) 110 | } 111 | 112 | removeGuiEntity (entity) { 113 | this.guiEntities.delete(entity) 114 | } 115 | 116 | /** 117 | * Querying the world 118 | */ 119 | collideAt (x, y, width, height, { tags = TAG_IS_SOLID | TAG_IS_DEATH | TAG_IS_COLLECTIBLE, ignore = null } = {}) { 120 | if (tags & TAG_IS_SOLID) { 121 | let result = this.solidAt(x, y, width, height, { ignore }) 122 | if (result) { 123 | return result 124 | } 125 | } 126 | 127 | let left = getCellX(x) 128 | let top = getCellY(y) 129 | let right = getCellX(x + width - 1) 130 | let bottom = getCellY(y + height - 1) 131 | 132 | for (let ix = left; ix <= right; ix++) { 133 | for (let iy = top; iy <= bottom; iy++) { 134 | let tile = this.tiles.getTileAt(ix, iy, tags) 135 | if (tile) { 136 | return tile 137 | } 138 | } 139 | } 140 | 141 | return null 142 | } 143 | 144 | solidAt (x, y, width, height, { ignore = null } = {}) { 145 | let left = getCellX(x) 146 | let top = getCellY(y) 147 | let right = getCellX(x + width - 1) 148 | let bottom = getCellY(y + height - 1) 149 | 150 | for (let ix = left; ix <= right; ix++) { 151 | for (let iy = top; iy <= bottom; iy++) { 152 | let tile = this.tiles.getTileAt(ix, iy, TAG_IS_SOLID) 153 | if (tile) { 154 | return tile 155 | } 156 | } 157 | } 158 | 159 | for (let solidEntity of this.solidEntities) { 160 | if (solidEntity === ignore || !solidEntity.collidable) { 161 | continue 162 | } 163 | 164 | if (overlapping({ x, y, width, height }, solidEntity)) { 165 | return solidEntity 166 | } 167 | } 168 | 169 | return null 170 | } 171 | 172 | step () { 173 | if (this.delay) { 174 | this.delay-- 175 | return 176 | } 177 | 178 | for (let entity of this.entities) { 179 | entity.step() 180 | } 181 | 182 | ThePlayer && ThePlayer.step() 183 | 184 | for (let entity of this.guiEntities) { 185 | entity.step() 186 | } 187 | 188 | TheCamera.step() 189 | } 190 | 191 | render () { 192 | TheRenderer.clear() 193 | 194 | this.bgRenderer.render() 195 | 196 | // Background layer 197 | TheRenderer.updateViewMatrix(BACKGROUND_LAYER) 198 | 199 | for (let entity of this.layers[BACKGROUND_LAYER]) { 200 | entity.render() 201 | } 202 | 203 | // Main layer 204 | TheRenderer.updateViewMatrix(MAIN_LAYER) 205 | 206 | this.tileRenderer.render() 207 | 208 | for (let entity of this.layers[MAIN_LAYER]) { 209 | entity.render() 210 | } 211 | 212 | ThePlayer && ThePlayer.render() 213 | 214 | // Foreground layer 215 | TheRenderer.updateViewMatrix(FOREGROUND_LAYER) 216 | 217 | for (let entity of this.layers[FOREGROUND_LAYER]) { 218 | entity.render() 219 | } 220 | 221 | // Foreground / UI effects 222 | TheGraphics.setTransform(1, 0, 0, 1, 0, 0) 223 | for (let entity of this.guiEntities) { 224 | entity.render() 225 | } 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Entities/Player/Wings.js: -------------------------------------------------------------------------------- 1 | import { approach, sign } from '../../utils' 2 | import { DASH_PREPARATION_TIME } from '../../constants' 3 | import { WingSprite } from '../../Assets' 4 | import { deltaTime } from '../../globals' 5 | import { TheGraphics } from '../../Graphics' 6 | import { TheRenderer } from '../../Renderer' 7 | import { FSM } from '../../FSM' 8 | 9 | const STATE_DETACHED = 0 10 | const STATE_ATTACHING = 1 11 | const STATE_ATTACHED = 2 12 | const STATE_FLAPPING = 3 13 | const STATE_DETACHING = 4 14 | 15 | class WingFSM extends FSM { 16 | constructor (wing) { 17 | super( 18 | { 19 | [STATE_DETACHED]: { 20 | execute: () => { 21 | this.alpha = 0 22 | } 23 | }, 24 | 25 | [STATE_ATTACHING]: { 26 | enter: (obj) => { 27 | this.timer = 0 28 | 29 | const angle = - 0.5 + Math.random() + (obj.facing === 1 ? Math.PI : 0) 30 | wing.x = wing.xFrom = obj.x + 20 * Math.cos(angle) 31 | wing.y = wing.yFrom = obj.y + 20 * Math.sin(angle) 32 | wing.rotation = 0 33 | wing.attachObject = obj 34 | }, 35 | 36 | execute: () => { 37 | this.timer += deltaTime 38 | 39 | let duration = DASH_PREPARATION_TIME + deltaTime * 3 40 | 41 | let alpha = this.timer / duration 42 | alpha = (2 - alpha) * alpha 43 | 44 | let xTo = wing.attachObject.x + wing.xOff 45 | let yTo = wing.attachObject.y + wing.yOff 46 | 47 | wing.x = wing.xFrom + (xTo - wing.xFrom) * alpha 48 | wing.y = wing.yFrom + (yTo - wing.yFrom) * alpha 49 | wing.alpha = alpha 50 | 51 | if (this.timer >= duration) { 52 | this.setState(STATE_ATTACHED) 53 | return 54 | } 55 | } 56 | }, 57 | 58 | [STATE_ATTACHED]: { 59 | execute: () => { 60 | wing.x = wing.attachObject.x + wing.xOff 61 | wing.y = wing.attachObject.y + wing.yOff 62 | wing.rotation = 0 63 | } 64 | }, 65 | 66 | [STATE_FLAPPING]: { 67 | enter: () => { 68 | this.timer = 20 69 | wing.rotation = Math.PI / 2 70 | }, 71 | execute: () => { 72 | wing.x = wing.attachObject.x + wing.xOff 73 | wing.y = wing.attachObject.y + wing.yOff 74 | let x = this.timer / 20 75 | wing.rotation = -sign(wing.facing) * Math.PI / 2 * x * x 76 | this.timer-- 77 | if (this.timer == 0) { 78 | this.setState(STATE_ATTACHED) 79 | } 80 | } 81 | }, 82 | 83 | [STATE_DETACHING]: { 84 | enter: (xSpeed, ySpeed) => { 85 | wing.attachObject = null 86 | this.xSpeed = xSpeed 87 | this.ySpeed = ySpeed 88 | this.rotationSpeed = Math.random() * 4 - 2 89 | }, 90 | execute: () => { 91 | wing.x += this.xSpeed * deltaTime 92 | wing.y += this.ySpeed * deltaTime 93 | wing.rotation += this.rotationSpeed * deltaTime 94 | wing.alpha = approach(wing.alpha, 0, 2 * deltaTime) 95 | 96 | if (wing.alpha <= 0) { 97 | this.setState(STATE_DETACHED) 98 | } 99 | } 100 | } 101 | }, 102 | STATE_DETACHED 103 | ) 104 | } 105 | } 106 | 107 | class Wing { 108 | constructor (facing) { 109 | this.x = 0 110 | this.y = 0 111 | this.facing = this.targetFacing = facing 112 | this.rotation = 0 113 | 114 | this.xOff = -2 * facing 115 | this.yOff = -6 116 | this.alpha = 0 117 | 118 | this.fsm = new WingFSM(this) 119 | } 120 | 121 | attach (obj) { 122 | if (this.attachObject !== obj) { 123 | this.fsm.setState(STATE_ATTACHING, obj) 124 | } 125 | } 126 | 127 | detach (xSpeed, ySpeed) { 128 | if (this.fsm.activeState !== STATE_DETACHED) { 129 | this.fsm.setState(STATE_DETACHING, xSpeed, ySpeed) 130 | } 131 | } 132 | 133 | step () { 134 | this.facing += (this.targetFacing - this.facing) / 5 135 | this.fsm.step() 136 | } 137 | 138 | setOffsetY (offset) { 139 | if (this.attachObject) { 140 | this.yOff = offset 141 | } 142 | } 143 | 144 | flap () { 145 | this.fsm.setState(STATE_FLAPPING) 146 | } 147 | 148 | render () { 149 | if (this.alpha <= 0) { 150 | return 151 | } 152 | 153 | TheRenderer.setAlpha(this.alpha) 154 | TheRenderer.drawSprite(WingSprite, this.x, this.y, 0, this.facing, 1, this.rotation) 155 | TheRenderer.resetAlpha() 156 | } 157 | } 158 | 159 | export class Wings { 160 | constructor () { 161 | this.wings = [new Wing(-1), new Wing(1)] 162 | this.renderOrder = [0, 1] 163 | } 164 | 165 | attach (obj) { 166 | for (let wing of this.wings) { 167 | wing.attach(obj) 168 | } 169 | } 170 | 171 | detach (xSpeed, ySpeed) { 172 | for (let wing of this.wings) { 173 | wing.detach(xSpeed, ySpeed) 174 | } 175 | } 176 | 177 | flap () { 178 | for (let wing of this.wings) { 179 | wing.flap() 180 | } 181 | } 182 | 183 | step () { 184 | for (let wing of this.wings) { 185 | wing.step() 186 | } 187 | } 188 | 189 | setOffsetY (offset) { 190 | for (let wing of this.wings) { 191 | wing.setOffsetY(offset) 192 | } 193 | } 194 | 195 | setFacing (facing) { 196 | switch (facing) { 197 | case 0: 198 | this.renderOrder = [0, 1] 199 | this.wings[0].targetFacing = -1 200 | this.wings[1].targetFacing = 1 201 | break 202 | case -1: 203 | this.renderOrder = [1, 0] 204 | this.wings[0].targetFacing = -1 205 | this.wings[1].targetFacing = -1 206 | break 207 | case 1: 208 | this.renderOrder = [0, 1] 209 | this.wings[0].targetFacing = 1 210 | this.wings[1].targetFacing = 1 211 | break 212 | } 213 | } 214 | 215 | render () { 216 | this.renderOrder.forEach(index => this.wings[index].render()) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/Editor/EditorController.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, TheCamera, TheSceneManager, TheEditorLevel } from '../globals' 2 | import { TheGraphics } from '../Graphics' 3 | 4 | import { LineBrush } from './Brushes/LineBrush' 5 | import { RectangleBrush } from './Brushes/RectangleBrush' 6 | 7 | import { PlayerStart } from './Entities/PlayerStart' 8 | import { EditorSolidTile } from './Entities/EditorSolidTile' 9 | import { EditorHurtTile } from './Entities/EditorHurtTile' 10 | import { EditorMovingPlatform } from './Entities/EditorMovingPlatform' 11 | import { Input } from '../Input' 12 | import { 13 | JUMP_OR_DASH, 14 | 15 | ENTITY_SERIALIZE_ID_SOLID_TILE, 16 | ENTITY_SERIALIZE_ID_HURT_TILE, 17 | ENTITY_SERIALIZE_ID_PLAYER_START, 18 | ENTITY_SERIALIZE_ID_MOVING_PLATFORM, 19 | ENTITY_SERIALIZE_ID_GOAL, 20 | ENTITY_SERIALIZE_ID_TEXT, 21 | ENTITY_SERIALIZE_ID_CHECKPOINT, 22 | ENTITY_SERIALIZE_ID_FADE_BLOCK 23 | } from '../constants' 24 | import { LevelLoaderEditorPlayable } from './LevelLoaderEditorPlayable' 25 | import { LevelSerializer } from './LevelSerializer' 26 | import { EditorGoal } from './Entities/EditorGoal' 27 | import { EditorText } from './Entities/EditorText' 28 | 29 | import { levels } from '../Assets/levels' 30 | import { forRectangularRegion } from '../utils' 31 | import { EditorCheckpoint } from './Entities/EditorCheckpoint' 32 | import { EditorFadeBlock } from './Entities/EditorFadeBlock' 33 | 34 | const DRAW_SOLID = '1' 35 | const DRAW_DEATH = '2' 36 | const DRAW_PLAYER = '3' 37 | const DRAW_MOVING_PLATFORM = '4' 38 | const DRAW_FADE_BLOCK = '5' 39 | const DRAW_GOAL = '6' 40 | const DRAW_TEXT = '7' 41 | const DRAW_CHECKPOINT = '8' 42 | 43 | function copyToClipboard (str) { 44 | const el = document.createElement('textarea') 45 | el.value = str 46 | document.body.appendChild(el) 47 | el.select() 48 | document.execCommand('copy') 49 | document.body.removeChild(el) 50 | } 51 | 52 | export class EditorController { 53 | constructor () { 54 | document.addEventListener('keydown', this.handleKeyDown.bind(this), false) 55 | this.mode = DRAW_SOLID 56 | } 57 | 58 | init (levelNumber) { 59 | if (this.inited) { 60 | return 61 | } 62 | 63 | TheCamera.addMouseListener(this) 64 | 65 | this.playerInstance = new PlayerStart(0, 0) 66 | TheWorld.addEntity(this.playerInstance) 67 | 68 | this.inited = true 69 | 70 | this.loadLevel(levelNumber) 71 | } 72 | 73 | loadLevel (levelNumber) { 74 | const { maps, entities } = levels[levelNumber] 75 | 76 | TheWorld.levelNumber = levelNumber 77 | 78 | TheWorld.clear() 79 | 80 | for (let key in maps) { 81 | let entityType = { 82 | [ENTITY_SERIALIZE_ID_SOLID_TILE]: EditorSolidTile, 83 | [ENTITY_SERIALIZE_ID_HURT_TILE]: EditorHurtTile 84 | }[key] 85 | maps[key].forEach(([x0, y0, w, h]) => { 86 | forRectangularRegion(x0, y0, x0 + w - 1, y0 + h - 1, (xi, yi) => TheWorld.addEntity(new entityType(xi, yi))) 87 | }) 88 | 89 | TheCamera.setBoundaries(TheWorld.getExtremes()) 90 | } 91 | 92 | entities.forEach(entity => { 93 | let args = entity.slice(1) 94 | switch (entity[0]) { 95 | case ENTITY_SERIALIZE_ID_PLAYER_START: 96 | this.setPlayerPosition(...args) 97 | break 98 | case ENTITY_SERIALIZE_ID_GOAL: 99 | TheWorld.addEntity(new EditorGoal(...args)) 100 | break 101 | case ENTITY_SERIALIZE_ID_MOVING_PLATFORM: 102 | TheWorld.addEntity(new EditorMovingPlatform(...args)) 103 | break 104 | case ENTITY_SERIALIZE_ID_FADE_BLOCK: 105 | TheWorld.addEntity(new EditorFadeBlock(...args)) 106 | break 107 | case ENTITY_SERIALIZE_ID_TEXT: 108 | TheWorld.addEntity(new EditorText(...args)) 109 | break 110 | case ENTITY_SERIALIZE_ID_CHECKPOINT: 111 | TheWorld.addEntity(new EditorCheckpoint(...args)) 112 | break 113 | } 114 | }) 115 | } 116 | 117 | drawSolid (x, y) { 118 | TheWorld.addEntity(new EditorSolidTile(x, y)) 119 | } 120 | 121 | drawHurtTile (x, y) { 122 | TheWorld.addEntity(new EditorHurtTile(x, y)) 123 | } 124 | 125 | drawMovingPlatform (x, y, width, height, direction) { 126 | TheWorld.addEntity(new EditorMovingPlatform(x, y, width, height, direction)) 127 | } 128 | 129 | drawGoal (x, y) { 130 | TheWorld.addEntity(new EditorGoal(x, y)) 131 | } 132 | 133 | drawInfoText (x, y) { 134 | let text = prompt('Text?') 135 | text = text.toUpperCase() 136 | .replace(/!/g, '[') 137 | .replace(/\./g, '\\') 138 | .replace(/,/g, ']') 139 | .replace(/\?/g, '^') 140 | .replace(/[^A-Z^\\[\] \n#]/g, '') 141 | 142 | if (text.length === 0) { 143 | return 144 | } 145 | 146 | TheWorld.addEntity(new EditorText(x, y, text)) 147 | } 148 | 149 | drawSolidRectangle (x0, y0, x1, y1) { 150 | forRectangularRegion(x0, y0, x1, y1, (x, y) => this.drawSolid(x, y)) 151 | } 152 | 153 | drawHurtRectangle (x0, y0, x1, y1) { 154 | forRectangularRegion(x0, y0, x1, y1, (x, y) => this.drawHurtTile(x, y)) 155 | } 156 | 157 | eraseRectangle (x0, y0, x1, y1) { 158 | forRectangularRegion(x0, y0, x1, y1, (x, y) => this.eraseAt(x, y)) 159 | } 160 | 161 | eraseAt (x, y) { 162 | TheWorld.removeAt(x, y) 163 | } 164 | 165 | setPlayerPosition (x, y) { 166 | if (TheWorld.getEntityAt(this.playerInstance.x, this.playerInstance.y) === this.playerInstance) { 167 | TheWorld.removeAt(this.playerInstance.x, this.playerInstance.y) 168 | } 169 | 170 | this.playerInstance.x = x 171 | this.playerInstance.y = y 172 | 173 | TheWorld.addEntity(this.playerInstance) 174 | } 175 | 176 | addOrUpdateMovingPlatform (x0, y0, x1, y1) { 177 | if (x0 === x1 && y0 === y1) { 178 | let entity = TheWorld.getEntityAt(x0, y0) 179 | if (entity instanceof EditorMovingPlatform) { 180 | let speedString = prompt('New direction', `${entity.xSpeed},${entity.ySpeed}`) 181 | let [newXSpeed, newYSpeed] = speedString.split(',').map(Number) 182 | 183 | // One of those has to be 0 184 | if (newXSpeed && newYSpeed) { 185 | return 186 | } 187 | 188 | entity.xSpeed = newXSpeed 189 | entity.ySpeed = newYSpeed 190 | return 191 | } 192 | } 193 | 194 | TheWorld.addEntity(new EditorMovingPlatform(x0, y0, x1 - x0 + 1, y1 - y0 + 1, 60, 0)) 195 | } 196 | 197 | addFadeBlock (x0, y0, x1, y1) { 198 | TheWorld.addEntity(new EditorFadeBlock(x0, y0, x1 - x0 + 1, y1 - y0 + 1)) 199 | } 200 | 201 | addCheckpoint (x, y) { 202 | TheWorld.addEntity(new EditorCheckpoint(x, y)) 203 | } 204 | 205 | handleKeyDown (event) { 206 | switch (event.key) { 207 | case DRAW_SOLID: 208 | case DRAW_DEATH: 209 | case DRAW_PLAYER: 210 | case DRAW_MOVING_PLATFORM: 211 | case DRAW_FADE_BLOCK: 212 | case DRAW_GOAL: 213 | case DRAW_TEXT: 214 | case DRAW_CHECKPOINT: 215 | this.mode = event.key 216 | break 217 | case 'c': 218 | if (event.ctrlKey) { 219 | event.preventDefault() 220 | 221 | const serializer = new LevelSerializer() 222 | copyToClipboard(serializer.serialize()) 223 | } 224 | break 225 | case '0': 226 | this.loadLevel(Number(prompt('Level number'))) 227 | break 228 | case 'Escape': 229 | TheSceneManager.loadNewLevel(TheEditorLevel) 230 | break 231 | } 232 | } 233 | 234 | handleMouseDown (event) { 235 | if (event.rightDown) { 236 | this.currentBrush = new RectangleBrush(this.eraseRectangle.bind(this)) 237 | } else { 238 | switch (this.mode) { 239 | case DRAW_SOLID: 240 | this.currentBrush = new RectangleBrush(this.drawSolidRectangle.bind(this)) 241 | break 242 | case DRAW_DEATH: 243 | this.currentBrush = new RectangleBrush(this.drawHurtRectangle.bind(this)) 244 | break 245 | case DRAW_PLAYER: 246 | this.currentBrush = new LineBrush(this.setPlayerPosition.bind(this)) 247 | break 248 | case DRAW_GOAL: 249 | this.currentBrush = new LineBrush(this.drawGoal.bind(this)) 250 | break 251 | case DRAW_MOVING_PLATFORM: 252 | this.currentBrush = new RectangleBrush(this.addOrUpdateMovingPlatform.bind(this)) 253 | break 254 | case DRAW_FADE_BLOCK: 255 | this.currentBrush = new RectangleBrush(this.addFadeBlock.bind(this)) 256 | break 257 | case DRAW_TEXT: 258 | this.currentBrush = new LineBrush(this.drawInfoText.bind(this)) 259 | break 260 | case DRAW_CHECKPOINT: 261 | this.currentBrush = new LineBrush(this.addCheckpoint.bind(this)) 262 | break 263 | } 264 | } 265 | this.currentBrush.start(event.tileX, event.tileY) 266 | 267 | // No boundaries when drawing 268 | TheCamera.setBoundaries({ 269 | minX: -Infinity, 270 | maxX: Infinity, 271 | minY: -Infinity, 272 | maxY: Infinity 273 | }) 274 | } 275 | 276 | handleMouseMove (event) { 277 | if (this.currentBrush && this.currentBrush.drawing) { 278 | this.currentBrush.update(event.tileX, event.tileY) 279 | } 280 | } 281 | 282 | handleMouseUp () { 283 | if (this.currentBrush && this.currentBrush.drawing) { 284 | this.currentBrush.end() 285 | this.currentBrush = null 286 | } 287 | 288 | TheCamera.setBoundaries(TheWorld.getExtremes()) 289 | } 290 | 291 | step () { 292 | if (Input.getKeyDown(JUMP_OR_DASH)) { 293 | TheSceneManager.loadNewLevel(new LevelLoaderEditorPlayable(TheWorld)) 294 | } 295 | } 296 | 297 | render () { 298 | if (this.currentBrush && this.currentBrush.drawing) { 299 | this.currentBrush.render() 300 | } 301 | 302 | TheGraphics.save() 303 | TheGraphics.setTransform(1, 0, 0, 1, 0, 0) 304 | let mode = { 305 | [DRAW_SOLID]: 'solid rectangle', 306 | [DRAW_DEATH]: 'hurt rectangle', 307 | [DRAW_PLAYER]: 'player', 308 | [DRAW_GOAL]: 'goal', 309 | [DRAW_MOVING_PLATFORM]: 'moving platform', 310 | [DRAW_FADE_BLOCK]: 'fade block', 311 | [DRAW_TEXT]: 'text', 312 | [DRAW_CHECKPOINT]: 'checkpoint', 313 | }[this.mode] 314 | let text = 'Current mode: ' + mode 315 | TheGraphics.font = '32px sans-serif' 316 | TheGraphics.fillStyle = '#000' 317 | TheGraphics.fillText(text, 2, 34) 318 | TheGraphics.fillStyle = '#fff' 319 | TheGraphics.fillText(text, 0, 32) 320 | TheGraphics.restore() 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /src/Entities/Player.js: -------------------------------------------------------------------------------- 1 | import { TheWorld, TheCamera, deltaTime, frame } from '../globals' 2 | import { TheRenderer } from '../Renderer' 3 | import { Input } from '../Input' 4 | import { 5 | LEFT_DIRECTION, 6 | RIGHT_DIRECTION, 7 | UP_DIRECTION, 8 | DOWN_DIRECTION, 9 | BOOST_TOGGLE, 10 | JUMP_OR_DASH, 11 | 12 | DASH_PREPARATION_TIME, 13 | DASH_DURATION, 14 | DASH_UP_SPEED, 15 | DASH_DOWN_SPEED, 16 | DASH_HORIZONTAL_SPEED, 17 | DASH_DIAGONAL_SPEED_X, 18 | DASH_DIAGONAL_SPEED_Y, 19 | MAX_FALLING_SPEED, 20 | RUN_SPEED_HORIZONTAL, 21 | AIR_ACC_MULTIPLIER, 22 | RUN_ACCELERATION, 23 | RUN_BOOST_FRACTION, 24 | JUMP_SPEED, 25 | JUMP_FIRST_PHASE_DURATION, 26 | JUMP_FIRST_PHASE_GRAVITY, 27 | DEFAULT_GRAVITY, 28 | DASH_FLOATING_DURATION, 29 | TAG_IS_DEATH, 30 | TILE_SIZE 31 | } from '../constants' 32 | 33 | import { approach, sign, hasTag } from '../utils' 34 | import { PlayerSprite, DashSound, JumpSound, ImpactSound, RebootSound } from '../Assets' 35 | import { Wings } from './Player/Wings' 36 | import { playSample } from '../Audio' 37 | import { FSM } from '../FSM' 38 | import { DeathAnimation } from './Player/DeathAnimation' 39 | import { Particle } from './Particle' 40 | import { FinishAnimation } from './FinishAnimation' 41 | import { Flash } from './Flash' 42 | import { GridEntity } from './GridEntity' 43 | 44 | class InputTimer { 45 | constructor () { 46 | // this.active = false // commented out to save bytes (interpret undefined as false) 47 | } 48 | 49 | start (count) { 50 | this.active = true 51 | this.timer = count 52 | } 53 | 54 | stop () { 55 | this.timer = 0 56 | this.active = false 57 | } 58 | 59 | isActive () { 60 | return this.active 61 | } 62 | 63 | step () { 64 | if (!this.active) return 65 | 66 | this.timer-- 67 | if (this.timer <= 0) { 68 | this.active = false 69 | } 70 | } 71 | } 72 | 73 | const STATE_DISABLED = 0 74 | const STATE_OFF = 1 75 | const STATE_TURNING_ON = 2 76 | const STATE_ON = 3 77 | const STATE_DASHING = 4 78 | const STATE_NORMAL = 5 79 | const STATE_FLOATING = 6 80 | 81 | class BoostModeFSM extends FSM { 82 | constructor (player) { 83 | super( 84 | { 85 | [STATE_DISABLED]: { 86 | enter: () => { 87 | player.detachWings() 88 | }, 89 | execute: () => { 90 | if (player.grounded) { 91 | this.setState(STATE_OFF) 92 | } 93 | } 94 | }, 95 | [STATE_OFF]: { 96 | enter: () => { 97 | player.detachWings() 98 | }, 99 | execute: () => { 100 | if (Input.getKey(BOOST_TOGGLE)) { 101 | this.setState(STATE_TURNING_ON) 102 | } 103 | } 104 | }, 105 | [STATE_TURNING_ON]: { 106 | enter: () => { 107 | this.counter = DASH_PREPARATION_TIME 108 | player.attachWings() 109 | }, 110 | execute: () => { 111 | if (!Input.getKey(BOOST_TOGGLE)) { 112 | this.setState(STATE_OFF) 113 | return 114 | } 115 | 116 | this.counter -= deltaTime 117 | if (this.counter <= 0) { 118 | this.setState(STATE_ON) 119 | } 120 | } 121 | }, 122 | [STATE_ON]: { 123 | execute: () => { 124 | if (!Input.getKey(BOOST_TOGGLE) && player.movementFSM.activeState !== STATE_DASHING) { 125 | this.setState(STATE_OFF) 126 | return 127 | } 128 | } 129 | } 130 | }, 131 | STATE_DISABLED 132 | ) 133 | } 134 | 135 | didAirDash () { 136 | this.setState(STATE_DISABLED) 137 | } 138 | } 139 | 140 | function getDashDirection (left, up, right, down) { 141 | return ( 142 | (LEFT_DIRECTION * left) | 143 | (UP_DIRECTION * up) | 144 | (RIGHT_DIRECTION * right) | 145 | (DOWN_DIRECTION * down) 146 | ) 147 | } 148 | 149 | function getIsValidDashDirection (direction) { 150 | return ( 151 | direction !== 0 && 152 | direction !== LEFT_DIRECTION + RIGHT_DIRECTION && 153 | direction !== UP_DIRECTION + DOWN_DIRECTION && 154 | direction !== LEFT_DIRECTION + RIGHT_DIRECTION + UP_DIRECTION + DOWN_DIRECTION 155 | ) 156 | } 157 | 158 | class WalkingDashingFSM extends FSM { 159 | constructor (player) { 160 | super( 161 | { 162 | [STATE_NORMAL]: { 163 | execute: () => { 164 | if (player.grounded) { 165 | this.airDashCount = 0 166 | 167 | player.resetGravity() 168 | } 169 | 170 | if (player.boostModeActive) { 171 | if (!this.handleDashInput(player)) { 172 | player.handleNormalControls() 173 | } 174 | } else { 175 | player.handleNormalControls() 176 | } 177 | } 178 | }, 179 | 180 | [STATE_DASHING]: { 181 | enter: (dashDirection) => { 182 | player.dash(dashDirection) 183 | 184 | this.dashTimer = DASH_DURATION 185 | if (player.ySpeed == -DASH_UP_SPEED) { 186 | this.dashTimer -= 0.06 187 | } 188 | 189 | if (!player.groundedTimer.isActive()) { 190 | this.airDashCount++ 191 | player.loseWings() 192 | } else { 193 | player.wings.flap() 194 | } 195 | 196 | TheWorld.addGuiEntity(new Flash()) 197 | }, 198 | 199 | execute: () => { 200 | this.dashTimer -= deltaTime 201 | if (this.dashTimer <= 0) { 202 | this.setState(STATE_FLOATING) 203 | } else { 204 | if (frame % 2 === 0) { 205 | TheWorld.addEntity( 206 | new Particle( 207 | player.boundingBox.x + Math.random() * player.bboxWidth, 208 | player.boundingBox.y + Math.random() * player.bboxHeight 209 | ) 210 | ) 211 | } 212 | } 213 | } 214 | }, 215 | 216 | [STATE_FLOATING]: { 217 | enter: () => { 218 | this.floatTimer = DASH_FLOATING_DURATION 219 | player.startFloat() 220 | }, 221 | 222 | execute: () => { 223 | // If we can dash... 224 | if (player.boostModeActive && this.airDashCount === 0) { 225 | // And pressed dash, we can immediately go back to DASHING state 226 | if (this.handleDashInput(player)) { 227 | return 228 | } 229 | } 230 | 231 | player.handleNormalControls() 232 | 233 | this.floatTimer -= deltaTime 234 | if (this.floatTimer <= 0) { 235 | this.setState(STATE_NORMAL) 236 | } 237 | }, 238 | 239 | leave: () => { 240 | if (player.dashDirection !== 1 << 3) { 241 | player.stopFloat() 242 | } 243 | } 244 | } 245 | }, 246 | STATE_NORMAL 247 | ) 248 | } 249 | 250 | handleDashInput (player) { 251 | if (!player.jumpInputTimer.isActive()) { 252 | return false 253 | } 254 | 255 | if (!player.grounded && this.airDashCount > 0) { 256 | return false 257 | } 258 | 259 | let dashDirection = getDashDirection( 260 | Input.getKey(LEFT_DIRECTION), 261 | Input.getKey(UP_DIRECTION), 262 | Input.getKey(RIGHT_DIRECTION), 263 | Input.getKey(DOWN_DIRECTION) 264 | ) || UP_DIRECTION 265 | 266 | const mask = getDashDirection( 267 | !player.solidAt(player.x - 1, player.y), 268 | !player.solidAt(player.x, player.y - 1), 269 | !player.solidAt(player.x + 1, player.y), 270 | !player.grounded 271 | ) 272 | 273 | dashDirection &= mask 274 | 275 | if (getIsValidDashDirection(dashDirection)) { 276 | this.setState(STATE_DASHING, dashDirection) 277 | return true 278 | } else { 279 | return false 280 | } 281 | } 282 | 283 | cancelDash (player, directions) { 284 | if (this.activeState !== STATE_DASHING) { 285 | return 286 | } 287 | 288 | player.dashDirection &= ~directions 289 | if (player.dashDirection === 0) { 290 | this.setState(STATE_FLOATING) 291 | } 292 | } 293 | } 294 | 295 | export class Player extends GridEntity { 296 | constructor (x, y) { 297 | super(x, y) 298 | 299 | this.startX = x 300 | this.startY = y 301 | 302 | this.x += TILE_SIZE / 2 303 | this.y += TILE_SIZE 304 | 305 | this.bboxOffsetX = 3 306 | this.bboxOffsetY = 8 307 | this.bboxWidth = 6 308 | this.bboxHeight = 8 309 | 310 | this.facing = 1 311 | this.xSpeed = 0 312 | this.ySpeed = 0 313 | 314 | // this.grounded = false // commented out to save bytes (interpret undefined as false) 315 | this.jumpTimer = 0 316 | 317 | this.isAlive = true 318 | // this.finishedLevel = false // commented out to save bytes (interpret undefined as false) 319 | this.slowDownFactor = 0.5 320 | 321 | this.xRemainder = 0 322 | this.yRemainder = 0 323 | 324 | this.dashDirection = 0 325 | 326 | this.defaultGravity = DEFAULT_GRAVITY 327 | this.gravity = this.defaultGravity 328 | this.maxFallingSpeed = MAX_FALLING_SPEED 329 | 330 | this.jumpInputTimer = new InputTimer() 331 | this.groundedTimer = new InputTimer() 332 | 333 | this.wings = new Wings() 334 | this.walkingIndex = 0 335 | this.spriteIndex = 0 336 | this.scaleX = 1 337 | this.scaleY = 1 338 | this.wingsYOffset = 0 339 | 340 | this.boostModeFSM = new BoostModeFSM(this) 341 | this.movementFSM = new WalkingDashingFSM(this) 342 | } 343 | 344 | step () { 345 | // Death animation before all else when dead 346 | if (!this.isAlive) { 347 | this.deathAnimation.step() 348 | this.wings.step() 349 | return 350 | } 351 | 352 | if (this.finishedLevel) { 353 | this.wings.step() 354 | this.slowDownFactor = approach(this.slowDownFactor, deltaTime, deltaTime) 355 | let multiplier = deltaTime * this.slowDownFactor 356 | this.move(this.xSpeed * multiplier, this.ySpeed * multiplier) 357 | return 358 | } 359 | 360 | if (this.y > TheWorld.height + 100) { 361 | this.die() 362 | return 363 | } 364 | 365 | this.grounded = ( 366 | this.solidAt(this.x, this.y + 1) && 367 | !this.collideAt(this.x, this.y + 1, { tags: TAG_IS_DEATH }) 368 | ) 369 | 370 | if (this.grounded) { 371 | this.groundedTimer.start(4) 372 | } 373 | 374 | if (Input.getKeyDown(JUMP_OR_DASH)) { 375 | this.jumpInputTimer.start(4) 376 | } 377 | if (!Input.getKey(JUMP_OR_DASH)) { 378 | this.jumpInputTimer.stop() 379 | } 380 | 381 | this.boostModeFSM.step() 382 | this.movementFSM.step() 383 | 384 | if (this.xSpeed !== 0) { 385 | this.facing = sign(this.xSpeed) 386 | } 387 | 388 | this.move(this.xSpeed * deltaTime, this.ySpeed * deltaTime) 389 | 390 | this.jumpInputTimer.step() 391 | this.groundedTimer.step() 392 | 393 | this.updateSprite() 394 | this.wings.step() 395 | } 396 | 397 | updateSprite () { 398 | this.spriteIndex = 0 399 | 400 | this.wings.setFacing(sign(this.xSpeed)) 401 | 402 | // Dashing 403 | if (this.movementFSM.activeState === STATE_DASHING) { 404 | if (this.xSpeed === 0) { 405 | this.spriteIndex = 0 406 | } 407 | else { 408 | this.spriteIndex = 3 409 | } 410 | } 411 | else if (!this.grounded) { 412 | // Jumping with horizontal speed 413 | if (Math.abs(this.xSpeed) > 100) { 414 | this.spriteIndex = 1 415 | if (this.ySpeed > 100) { 416 | this.spriteIndex = 4 417 | } 418 | } 419 | // Falling 420 | else if (this.ySpeed > 100) { 421 | this.spriteIndex = 5 422 | } 423 | // Jumping up 424 | else { 425 | this.spriteIndex = 0 426 | } 427 | } 428 | // Walking 429 | else if (Math.abs(this.xSpeed) > 100) { 430 | this.spriteIndex = Math.floor(this.walkingIndex) % 2 + 1 431 | this.walkingIndex += 0.1 432 | } 433 | 434 | const longate = this.ySpeed < 0 ? Math.min(0.1, -this.ySpeed / 100) : 0 435 | this.scaleX = (1 - longate) 436 | this.scaleY = 1 + longate * 2 437 | 438 | this.wings.setOffsetY([-8, -7, -8, -7, -7, -8, -7][this.spriteIndex]) * this.scaleY 439 | } 440 | 441 | attachWings () { 442 | this.wings.attach(this) 443 | } 444 | 445 | detachWings () { 446 | const dx = Math.max(10, Math.abs(this.xSpeed / 8)) * sign(this.xSpeed || this.facing) 447 | this.wings.detach(-dx, -this.ySpeed / 8) 448 | } 449 | 450 | loseWings () { 451 | this.detachWings() 452 | this.boostModeFSM.didAirDash() 453 | } 454 | 455 | dash (direction) { 456 | this.dashDirection = direction 457 | 458 | this.xSpeed = 0 459 | this.ySpeed = 0 460 | if (direction & 0x01) this.xSpeed-- 461 | if (direction & 0x02) this.ySpeed-- 462 | if (direction & 0x04) this.xSpeed++ 463 | if (direction & 0x08) this.ySpeed++ 464 | if (this.xSpeed !== 0 && this.ySpeed !== 0) { 465 | this.xSpeed *= DASH_DIAGONAL_SPEED_X 466 | this.ySpeed *= DASH_DIAGONAL_SPEED_Y 467 | } else { 468 | this.xSpeed *= DASH_HORIZONTAL_SPEED 469 | this.ySpeed *= DASH_UP_SPEED 470 | } 471 | 472 | if (this.ySpeed == DASH_UP_SPEED) { 473 | this.ySpeed = DASH_DOWN_SPEED 474 | } 475 | 476 | playSample(DashSound) 477 | } 478 | 479 | handleNormalControls () { 480 | let targetSpeed = RUN_SPEED_HORIZONTAL * (-Input.getKey(LEFT_DIRECTION) + Input.getKey(RIGHT_DIRECTION)) 481 | const multiplier = this.grounded ? 1 : AIR_ACC_MULTIPLIER 482 | if (targetSpeed === 0) { 483 | this.xSpeed = approach(this.xSpeed, targetSpeed, multiplier * RUN_ACCELERATION * deltaTime) 484 | } else if (this.xSpeed === 0) { 485 | if (this.grounded) { 486 | // If on ground the player can have a little boost 487 | this.xSpeed = targetSpeed * RUN_BOOST_FRACTION 488 | } else { 489 | // but in the air still needs some acceleration 490 | this.xSpeed = approach(this.xSpeed, targetSpeed, multiplier * RUN_ACCELERATION * deltaTime) 491 | } 492 | } else if (sign(targetSpeed) === sign(this.xSpeed)) { 493 | this.xSpeed = approach(this.xSpeed, targetSpeed, multiplier * RUN_ACCELERATION * deltaTime) 494 | } else if (sign(targetSpeed) !== sign(this.xSpeed)) { 495 | this.xSpeed = approach(this.xSpeed, targetSpeed, 4 * multiplier * RUN_ACCELERATION * deltaTime) 496 | } 497 | 498 | if (!this.boostModeActive && this.groundedTimer.isActive() && this.jumpInputTimer.isActive()) { 499 | this.ySpeed = -JUMP_SPEED 500 | this.jumpTimer = JUMP_FIRST_PHASE_DURATION 501 | this.jumpInputTimer.stop() 502 | 503 | playSample(JumpSound) 504 | } 505 | 506 | if (!this.grounded) { 507 | this.jumpTimer -= deltaTime 508 | if (this.jumpTimer > 0 && Input.getKey(JUMP_OR_DASH)) { 509 | this.ySpeed += JUMP_FIRST_PHASE_GRAVITY * deltaTime 510 | } 511 | else if (this.jumpTimer <= 0 || !Input.getKey(JUMP_OR_DASH)) { 512 | this.ySpeed = approach(this.ySpeed, this.maxFallingSpeed, this.gravity * deltaTime) 513 | this.jumpTimer = 0 514 | } 515 | } 516 | } 517 | 518 | move (dx, dy) { 519 | let stopX = (collider) => { 520 | // Only let it kill if the player had somewhat of a horizontal speed into it 521 | if (hasTag(collider, TAG_IS_DEATH) && Math.abs(dx) >= 0.5) { 522 | this.die() 523 | return 524 | } 525 | 526 | this.reduceHorizontalMovement(collider.xSpeed) 527 | } 528 | 529 | let stopY = (collider) => { 530 | if (hasTag(collider, TAG_IS_DEATH)) { 531 | this.die() 532 | return 533 | } 534 | 535 | this.reduceVerticalMovement(collider.ySpeed) 536 | } 537 | 538 | if (Math.abs(dx) > Math.abs(dy)) { 539 | this.moveX(dx, stopX) 540 | this.moveY(dy, stopY) 541 | } else { 542 | this.moveY(dy, stopY) 543 | this.moveX(dx, stopX) 544 | } 545 | } 546 | reduceHorizontalMovement (colliderXSpeed = 0) { 547 | let targetSpeed = sign(colliderXSpeed) === sign(this.xSpeed) ? colliderXSpeed : 0 548 | let impact = Math.abs(targetSpeed - Math.abs(this.xSpeed)) 549 | this.xSpeed = targetSpeed 550 | 551 | if (impact >= 200) { 552 | this.showImpactFeedback() 553 | this.movementFSM.cancelDash(this, getDashDirection(1, 0, 1, 0)) 554 | } 555 | } 556 | 557 | reduceVerticalMovement (colliderYSpeed = 0) { 558 | let targetSpeed = sign(colliderYSpeed) === sign(this.ySpeed) ? colliderYSpeed : 0 559 | let impact = Math.abs(targetSpeed - Math.abs(this.ySpeed)) 560 | 561 | if (impact >= 230 || this.ySpeed < -200) { 562 | this.showImpactFeedback() 563 | 564 | // Show some dust 565 | for (let i = -2; i <= 2; i++) { 566 | TheWorld.addEntity(new Particle(this.x + i * 4, this.y - 1 - Math.random() * 2)) 567 | } 568 | 569 | // Freeze the world for a few frames 570 | TheWorld.delay = Math.abs(this.ySpeed) == MAX_FALLING_SPEED ? 2 : 1 571 | 572 | this.jumpTimer = 0 573 | 574 | this.movementFSM.cancelDash(this, getDashDirection(0, 1, 0, 1)) 575 | } 576 | 577 | this.ySpeed = targetSpeed 578 | } 579 | 580 | showImpactFeedback () { 581 | TheCamera.addShake(0.4) 582 | playSample(ImpactSound, 0.2) 583 | } 584 | 585 | resetGravity () { 586 | this.gravity = this.defaultGravity 587 | } 588 | 589 | startFloat () { 590 | this.gravity = 0 591 | } 592 | 593 | stopFloat () { 594 | this.gravity = this.defaultGravity 595 | } 596 | 597 | moveX (amount, onCollide) { 598 | this.xRemainder += amount 599 | let move = Math.round(this.xRemainder) 600 | 601 | if (move !== 0) { 602 | this.xRemainder -= move 603 | let step = sign(move) 604 | 605 | while (move !== 0) { 606 | let collider = this.solidAt(this.x + step, this.y) 607 | if (!collider) { 608 | this.x += step 609 | move -= step 610 | } 611 | else { 612 | onCollide && onCollide(collider) 613 | break 614 | } 615 | } 616 | } 617 | } 618 | 619 | moveY (amount, onCollide) { 620 | this.yRemainder += amount 621 | let move = Math.round(this.yRemainder) 622 | 623 | if (move !== 0) { 624 | this.yRemainder -= move 625 | let step = sign(move) 626 | 627 | while (move !== 0) { 628 | let collider = this.solidAt(this.x, this.y + step) 629 | if (!collider) { 630 | this.y += step 631 | move -= step 632 | } 633 | else { 634 | onCollide && onCollide(collider) 635 | break 636 | } 637 | } 638 | } 639 | } 640 | 641 | collideAt (x, y, params) { 642 | return TheWorld.collideAt( 643 | x - this.bboxOffsetX, 644 | y - this.bboxOffsetY, 645 | this.bboxWidth, 646 | this.bboxHeight, 647 | params 648 | ) 649 | } 650 | 651 | solidAt (x, y) { 652 | return TheWorld.solidAt( 653 | x - this.bboxOffsetX, 654 | y - this.bboxOffsetY, 655 | this.bboxWidth, 656 | this.bboxHeight 657 | ) 658 | } 659 | 660 | get boundingBox () { 661 | return { 662 | x: this.x - this.bboxOffsetX, 663 | y: this.y - this.bboxOffsetY, 664 | width: this.bboxWidth, 665 | height: this.bboxHeight, 666 | centerX: this.x - this.bboxOffsetX + this.bboxWidth / 2, 667 | centerY: this.y - this.bboxOffsetY + this.bboxHeight / 2 668 | } 669 | } 670 | 671 | get boostModeActive () { 672 | return this.boostModeFSM.activeState === STATE_ON 673 | } 674 | 675 | get isDashing () { 676 | return this.movementFSM.activeState === STATE_DASHING 677 | } 678 | 679 | isRiding (platform) { 680 | return ( 681 | platform.y === this.y && 682 | this.x - this.bboxOffsetX + this.bboxWidth > platform.x && 683 | this.x - this.bboxOffsetX < platform.x + platform.width 684 | ) 685 | } 686 | 687 | render () { 688 | this.wings.render() 689 | 690 | if (!this.isAlive) { 691 | this.deathAnimation.render() 692 | return 693 | } 694 | 695 | TheRenderer.drawSprite(PlayerSprite, this.x, this.y, this.spriteIndex, this.facing * this.scaleX, this.scaleY) 696 | } 697 | 698 | die () { 699 | if (!this.isAlive) { 700 | return 701 | } 702 | 703 | this.isAlive = false 704 | this.deathAnimation = new DeathAnimation(this.x, this.boundingBox.centerY) 705 | this.detachWings() 706 | } 707 | 708 | setFinished () { 709 | if (this.finishedLevel) { 710 | return 711 | } 712 | 713 | this.finishedLevel = true 714 | this.finishAnimation = new FinishAnimation() 715 | TheWorld.addEntity(this.finishAnimation) 716 | TheCamera.addShake(1) 717 | playSample(RebootSound, 0.2, true) 718 | } 719 | } 720 | --------------------------------------------------------------------------------