├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── advzip.js ├── assets ├── eyes.svg ├── hangman.svg ├── head.svg ├── limb.svg ├── scaffold.svg └── torso.svg ├── checkBundleLimit.js ├── package-lock.json ├── package.json ├── publish.js ├── screens ├── editor.png └── screen1.png ├── src ├── ZzFX.micro.js ├── assets.ts ├── bezier.ts ├── camera.ts ├── colisions-masks.ts ├── control.ts ├── editor │ ├── editor-renderer.ts │ ├── editor-ui.ts │ ├── editor.ts │ ├── layout.ts │ ├── listeners.ts │ ├── manipulator.ts │ ├── objects.ts │ └── serialization.ts ├── engine.ts ├── foliage.ts ├── game.ts ├── index.html ├── index.ts ├── level.interface.ts ├── levels.ts ├── loader.ts ├── menu.ts ├── music.ts ├── particles.ts ├── physics │ ├── constants.ts │ ├── helpers.ts │ ├── physics.ts │ ├── player-physics.ts │ └── shapes.ts ├── plants.ts ├── player.ts ├── random.ts ├── renderer │ ├── layer.ts │ ├── renderer.ts │ └── sprite-renderer.ts ├── saves.ts ├── utils.ts └── vector.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | assets/tex.png~ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ZzFX.micro.js 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "all" 2 | printWidth: 79 3 | endOfLine: "lf" 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.tslintIntegration": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mateusz Tomczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Wandering Wraith 2 | 3 | A 2d platformer for [js13kGames](https://js13kgames.com/) 2019 edition challenge. 4 | 5 | You can play it [here](https://tulustul.github.io/The-Wandering-Wraith/) or on the [contest page](https://js13kgames.com/entries/the-wandering-wraith) 6 | 7 | [Post mortem](https://medium.com/@mateusz.tomczyk/a-story-of-making-a-13-kb-game-in-30-days-the-wandering-wraith-post-mortem-9847c8992f49) 8 | 9 | ![Game screenshot](/screens/screen1.png) 10 | 11 | ## Controls 12 | 13 | - left and rigth arrows for movement 14 | - space for jumping 15 | 16 | ## Editor 17 | 18 | The game comes with a built-in editor available in development build only. 19 | ![Game screenshot](/screens/editor.png) 20 | 21 | Some non-obvious things about editor: 22 | 23 | - press "e" to enable it 24 | - you can delete objects with "delete" key 25 | - when path point is selected you can: 26 | - cut it using "c" 27 | - toggle between straight lines and bezier curves using "v" 28 | 29 | ## Getting started 30 | 31 | - `npm install` 32 | 33 | ## For development 34 | 35 | - `npm run start` 36 | 37 | A dev server is started at `http://localhost:8080` 38 | 39 | ## For production 40 | 41 | - `npm run build` 42 | 43 | Ready to use bundle is located in `/dist` directory. 44 | 45 | Thanks for Frank Force for his awesome [ZzFX](https://zzfx.3d2k.com/). 46 | -------------------------------------------------------------------------------- /advzip.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | const { execFile } = require("child_process"); 3 | const advzip = require("advzip-bin"); 4 | 5 | execFile( 6 | advzip, 7 | ["--recompress", "--shrink-extra", "dist/bundle.zip"], 8 | err => { 9 | console.log("ZIP file minified!"); 10 | }, 11 | ); 12 | -------------------------------------------------------------------------------- /assets/eyes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/hangman.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/head.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /assets/limb.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/scaffold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/torso.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /checkBundleLimit.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const fs = require("fs"); 4 | 5 | const prettyBytes = require("pretty-bytes"); 6 | const colors = require("colors"); 7 | 8 | const limit = 13 * 1024; 9 | 10 | const size = fs.statSync("dist/bundle.zip").size; 11 | 12 | const color = size <= limit ? "green" : "red"; 13 | console.log(`Bundle has ${formatBytes(size)}.`[color]); 14 | 15 | if (size <= limit) { 16 | const available = limit - size; 17 | console.log(`You have ${formatBytes(available)} available.`[color]); 18 | } else { 19 | const exceeded = size - limit; 20 | console.error( 21 | `Bundle size limit is exceeded by ${formatBytes(exceeded)}.`[color], 22 | ); 23 | } 24 | 25 | function formatBytes(bytes) { 26 | const percent = ((bytes / limit) * 100).toFixed(1); 27 | return `${prettyBytes(bytes)} (${bytes} bytes, ${percent}%)`; 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wandering-wraith", 3 | "version": "1.0.0", 4 | "description": "A 13kB 2d platformer", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --mode=development --host 0.0.0.0", 8 | "build": "webpack --mode=production --env=prod && ./advzip.js && ./checkBundleLimit.js", 9 | "publish": "npm run build && ./publish.js" 10 | }, 11 | "author": "Mateusz Tomczyk", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "advzip-bin": "^1.1.0", 15 | "colors": "^1.3.3", 16 | "gh-pages": "^2.1.1", 17 | "html-webpack-inline-svg-plugin": "^1.2.4", 18 | "html-webpack-plugin": "^3.2.0", 19 | "pretty-bytes": "^5.3.0", 20 | "script-ext-html-webpack-plugin": "^2.1.4", 21 | "ts-loader": "^4.5.0", 22 | "typescript": "^3.5.3", 23 | "webpack": "^4.39.2", 24 | "webpack-cli": "^3.3.6", 25 | "webpack-conditional-loader": "^1.0.12", 26 | "webpack-dev-server": "^3.8.0", 27 | "zip-webpack-plugin": "^3.0.0", 28 | "extra-watch-webpack-plugin": "^1.0.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /publish.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const ghpages = require("gh-pages"); 4 | 5 | ghpages.publish("dist", err => { 6 | if (err) { 7 | console.error(`Failed to publish: ${err}`); 8 | } else { 9 | console.log("Publishing successfull"); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /screens/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tulustul/The-Wandering-Wraith/24a019be1cab3e6cd20cf82136a9f1cb8bbebd84/screens/editor.png -------------------------------------------------------------------------------- /screens/screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tulustul/The-Wandering-Wraith/24a019be1cab3e6cd20cf82136a9f1cb8bbebd84/screens/screen1.png -------------------------------------------------------------------------------- /src/ZzFX.micro.js: -------------------------------------------------------------------------------- 1 | zzfx_v=.5;zzfx_x=new AudioContext;zzfx=(e,f,a,b=1,d=.1,g=0,h=0,k=0,l=0)=>{let S=44100,P=Math.PI;a*=2*P/S;a*=1+f*(2*Math.random()-1);g*=1E3*P/(S**2);b=0 { 18 | return new Promise((resolve, reject) => { 19 | const svg = document.getElementById(id)!; 20 | const xml = new XMLSerializer().serializeToString(svg); 21 | 22 | const svg64 = btoa(xml); 23 | const b64Start = "data:image/svg+xml;base64,"; 24 | const image64 = b64Start + svg64; 25 | 26 | const img = new Image(); 27 | img.onload = () => resolve(img); 28 | img.src = image64; 29 | }); 30 | } 31 | 32 | async function preparePlants(): Promise { 33 | const r = new Random(1); 34 | const sr = new SpriteRenderer(); 35 | const plants: PlantDefinition[] = []; 36 | for (let depth = 4; depth < 11; depth++) { 37 | plants.push({ 38 | frames_: await animateTree( 39 | sr, 40 | depth, 41 | r.nextFloat() / 4 + 0.3, 42 | 5 * depth, 43 | depth, 44 | ), 45 | spread: 25 * Math.pow(depth, 1.3), 46 | mask_: TREE_GROUND_MASK, 47 | }); 48 | } 49 | 50 | for (let i = 0; i < 4; i++) { 51 | plants.push({ 52 | frames_: await animateGrass(sr, 1 + i * 0.5, i), 53 | spread: 6 + i, 54 | mask_: GRASS_MASK, 55 | }); 56 | } 57 | return plants; 58 | } 59 | 60 | export const assets: Assets = {} as any; 61 | 62 | export async function prepareAssets() { 63 | assets.head_ = await svgToImg("h"); 64 | assets.eyes = await svgToImg("e"); 65 | assets.torso = await svgToImg("t"); 66 | assets.limb = await svgToImg("l"); 67 | assets.scaffold = await svgToImg("s"); 68 | assets.hangman = await svgToImg("m"); 69 | assets.plants = await preparePlants(); 70 | } 71 | -------------------------------------------------------------------------------- /src/bezier.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | 3 | export function* generateBezierSegments( 4 | controls: Vector2[], 5 | detail: number, 6 | ): IterableIterator<[Vector2, Vector2]> { 7 | let lastPoint: Vector2 | null = null; 8 | 9 | let a0: Vector2, a1: Vector2, a2: Vector2, a3: Vector2; 10 | for (let i = 0; i < controls.length - 2; i += 4) { 11 | a0 = controls[i]; 12 | a1 = controls[i + 1]; 13 | a2 = controls[i + 2]; 14 | 15 | a3 = controls[i + 3]; 16 | for (let j = 0; j < 1; j += detail) { 17 | const newPoint = cubicBezier(a0, a1, a2, a3, j); 18 | if (lastPoint) { 19 | yield [lastPoint, newPoint]; 20 | } 21 | lastPoint = newPoint; 22 | } 23 | } 24 | } 25 | 26 | function cubicBezier( 27 | p1: Vector2, 28 | p2: Vector2, 29 | p3: Vector2, 30 | p4: Vector2, 31 | t: number, 32 | ) { 33 | return new Vector2( 34 | cubicBezierPoint(p1.x, p2.x, p3.x, p4.x, t), 35 | cubicBezierPoint(p1.y, p2.y, p3.y, p4.y, t), 36 | ); 37 | } 38 | 39 | function cubicBezierPoint( 40 | a0: number, 41 | a1: number, 42 | a2: number, 43 | a3: number, 44 | t: number, 45 | ) { 46 | return ( 47 | Math.pow(1 - t, 3) * a0 + 48 | 3 * Math.pow(1 - t, 2) * t * a1 + 49 | 3 * (1 - t) * Math.pow(t, 2) * a2 + 50 | Math.pow(t, 3) * a3 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/camera.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | import { Engine } from "./engine"; 3 | import { lerp } from "./utils"; 4 | 5 | export class Camera { 6 | pos = new Vector2(); 7 | 8 | constructor(private engine: Engine) {} 9 | 10 | update_() { 11 | const w = this.engine.canvas_.width; 12 | const h = this.engine.canvas_.height; 13 | 14 | const target = this.engine.player.body_.pos; 15 | 16 | const [maxX, maxY, x, y] = [ 17 | this.engine.level_.size_.x - w, 18 | this.engine.level_.size_.y - h, 19 | target.x - w / 2, 20 | this.moveAtAxis(this.pos.y, target.y - h / 1.7, -5, 50), 21 | ]; 22 | this.pos.x = Math.min(Math.max(0, x), maxX); 23 | this.pos.y = Math.min(Math.max(0, y), maxY); 24 | } 25 | 26 | private moveAtAxis( 27 | current: number, 28 | target: number, 29 | lowerLimit: number, 30 | upperLimit: number, 31 | ) { 32 | const d = current - target; 33 | if (d < lowerLimit || d > upperLimit) { 34 | return lerp( 35 | current, 36 | target + (d < lowerLimit ? lowerLimit : upperLimit), 37 | 0.12, 38 | ); 39 | } 40 | return current; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/colisions-masks.ts: -------------------------------------------------------------------------------- 1 | export const GROUND_MASK = 1; 2 | export const GRASS_MASK = 1 << 1; 3 | export const TREE_GROUND_MASK = 1 << 2; 4 | -------------------------------------------------------------------------------- /src/control.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "./game"; 2 | import { MenuMode } from "./menu"; 3 | 4 | export class Control { 5 | keys_ = new Map(); 6 | 7 | constructor(private game: Game) {} 8 | 9 | init() { 10 | window.addEventListener("keydown", event => { 11 | this.keys_.set(event.code, true); 12 | 13 | if (event.key === "Escape") { 14 | if (this.game.menu.mode === MenuMode.stats) { 15 | this.game.menu.showCredits(); 16 | } else if (this.game.menu.mode === MenuMode.credits) { 17 | this.game.startNewGame(); 18 | } else { 19 | this.game.togglePause(); 20 | } 21 | } 22 | }); 23 | 24 | window.addEventListener("keyup", event => { 25 | this.keys_.set(event.code, false); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/editor/editor-renderer.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "./editor"; 2 | import { Vector2 } from "../vector"; 3 | import { PathCommandType } from "../level.interface"; 4 | 5 | export class EditorRenderer { 6 | ctx: CanvasRenderingContext2D; 7 | 8 | constructor(private editor: Editor) {} 9 | 10 | render(ctx: CanvasRenderingContext2D) { 11 | this.ctx = ctx; 12 | 13 | if (Math.round(this.engine.time_) % 500 === 0) { 14 | this.engine.renderer.terrainLayer.activate(); 15 | this.engine.renderer.renderTerrain(); 16 | this.engine.renderer.renderPlatforms(); 17 | this.engine.renderer.renderSpikes(); 18 | } 19 | 20 | this.engine.renderer.baseLayer.activate(); 21 | 22 | const pos = this.engine.camera.pos; 23 | this.ctx.save(); 24 | this.ctx.translate(-pos.x, -pos.y); 25 | 26 | this.drawPaths(); 27 | 28 | if (this.editor.drawColisionHelpers) { 29 | this.drawColisionHelpers(); 30 | } 31 | 32 | if (this.editor.drawPlantsHelpers) { 33 | this.drawPlantsHelpers(); 34 | } 35 | 36 | this.drawObjects(); 37 | this.drawControlPoints(); 38 | this.drawAreaSelection(); 39 | 40 | this.renderLevelBoundaries(); 41 | 42 | this.ctx.restore(); 43 | } 44 | 45 | private get engine() { 46 | return this.editor.engine; 47 | } 48 | 49 | private drawColisionHelpers() { 50 | this.ctx.strokeStyle = "#ff0"; 51 | this.ctx.lineWidth = 2; 52 | for (const body of this.engine.physics.staticBodies) { 53 | this.ctx.beginPath(); 54 | this.ctx.moveTo(body.start_.x, body.start_.y); 55 | this.ctx.lineTo(body.end_.x, body.end_.y); 56 | this.ctx.stroke(); 57 | this.ctx.closePath(); 58 | } 59 | 60 | this.ctx.fillStyle = "#f00"; 61 | this.ctx.strokeStyle = "#f00"; 62 | 63 | const body = this.engine.player.body_; 64 | this.ctx.save(); 65 | this.ctx.beginPath(); 66 | this.ctx.moveTo(body.pos.x, body.pos.y); 67 | this.ctx.lineTo(body.pos.x + body.vel.x * 2, body.pos.y + body.vel.y * 2); 68 | this.ctx.closePath(); 69 | this.ctx.stroke(); 70 | 71 | for (const point of body.contactPoints) { 72 | this.ctx.beginPath(); 73 | this.ctx.arc(point.x, point.y, 2, 0, 2 * Math.PI); 74 | this.ctx.fill(); 75 | this.ctx.closePath(); 76 | } 77 | 78 | this.ctx.fillStyle = "#f005"; 79 | this.ctx.beginPath(); 80 | this.ctx.arc(body.pos.x, body.pos.y, 10, 0, Math.PI * 2); 81 | this.ctx.closePath(); 82 | this.ctx.fill(); 83 | 84 | this.ctx.restore(); 85 | this.ctx.closePath(); 86 | } 87 | 88 | private drawPlantsHelpers() { 89 | this.ctx.fillStyle = "green"; 90 | const minX = this.engine.camera.pos.x; 91 | const maxX = this.engine.camera.pos.x + this.engine.canvas_.width; 92 | for (let x = minX; x < maxX; x += this.engine.foliage.GRID_SIZE) { 93 | const cell = Math.floor(x / this.engine.foliage.GRID_SIZE); 94 | for (const foliage of this.engine.foliage.entities_[cell] || []) { 95 | this.ctx.fillRect(foliage.pos.x - 1, foliage.pos.y - 1, 2, 2); 96 | } 97 | } 98 | } 99 | private drawControlPoints() { 100 | const ctx = this.ctx; 101 | ctx.lineWidth = 1; 102 | ctx.strokeStyle = "red"; 103 | let to: Vector2; 104 | let lastPoint: Vector2; 105 | for (const pathCommand of this.editor.engine.level_.pathCommands!) { 106 | switch (pathCommand.type) { 107 | case PathCommandType.move: 108 | to = pathCommand.points![0]; 109 | this.drawPoint(ctx, to, "blue"); 110 | lastPoint = to; 111 | break; 112 | case PathCommandType.line: 113 | to = pathCommand.points![0]; 114 | this.drawPoint(ctx, to, "darkorange"); 115 | lastPoint = to; 116 | break; 117 | case PathCommandType.bezier: 118 | const [c1, c2, to_] = pathCommand.points!; 119 | this.drawPoint(ctx, c1, "red"); 120 | this.drawPoint(ctx, c2, "red"); 121 | this.drawPoint(ctx, to_, "darkorange"); 122 | ctx.beginPath(); 123 | ctx.moveTo(lastPoint!.x, lastPoint!.y); 124 | ctx.lineTo(c1.x, c1.y); 125 | ctx.stroke(); 126 | ctx.beginPath(); 127 | ctx.moveTo(to_.x, to_.y); 128 | ctx.lineTo(c2.x, c2.y); 129 | ctx.stroke(); 130 | lastPoint = to_; 131 | break; 132 | case PathCommandType.close: 133 | ctx.closePath(); 134 | ctx.stroke(); 135 | break; 136 | } 137 | } 138 | 139 | for (const o of this.editor.engine.level_.objects!) { 140 | this.drawPoint(ctx, o.pos, "blue"); 141 | } 142 | } 143 | 144 | private drawPaths() { 145 | let to: Vector2; 146 | this.ctx.strokeStyle = "yellow"; 147 | this.ctx.fillStyle = "transparent"; 148 | for (const pathCommand of this.engine.level_.pathCommands) { 149 | switch (pathCommand.type) { 150 | case PathCommandType.move: 151 | to = pathCommand.points![0]; 152 | this.ctx.beginPath(); 153 | this.ctx.moveTo(to.x, to.y); 154 | break; 155 | case PathCommandType.line: 156 | to = pathCommand.points![0]; 157 | this.ctx.lineTo(to.x, to.y); 158 | break; 159 | case PathCommandType.bezier: 160 | const [c1, c2, to_] = pathCommand.points!; 161 | this.ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, to_.x, to_.y); 162 | break; 163 | case PathCommandType.close: 164 | this.ctx.closePath(); 165 | this.ctx.stroke(); 166 | break; 167 | } 168 | } 169 | } 170 | 171 | private drawPoint(ctx: CanvasRenderingContext2D, p: Vector2, fill: string) { 172 | ctx.fillStyle = fill; 173 | if (this.editor.manipulator.selectedPoints.has(p)) { 174 | ctx.fillStyle = "green"; 175 | } 176 | if (p === this.editor.manipulator.focusedPoint) { 177 | ctx.fillStyle = "yellow"; 178 | } 179 | ctx.beginPath(); 180 | ctx.arc(p.x, p.y, 3, 0, 2 * Math.PI); 181 | ctx.fill(); 182 | ctx.closePath(); 183 | } 184 | 185 | private drawAreaSelection() { 186 | if (this.editor.manipulator.selectionArea) { 187 | this.ctx.fillStyle = "#fff2"; 188 | const [from, to] = this.editor.manipulator.selectionArea; 189 | const relTo = to.copy().sub_(from); 190 | this.ctx.rect(from.x, from.y, relTo.x, relTo.y); 191 | this.ctx.fill(); 192 | } 193 | } 194 | 195 | private drawObjects() { 196 | const sizes: { [key: string]: [number, number] } = { 197 | platform: [15, 5], 198 | platformH1: [40, 10], 199 | platformH2: [80, 10], 200 | platformV1: [10, 40], 201 | platformV2: [10, 80], 202 | platformB1: [40, 40], 203 | platformB2: [60, 60], 204 | }; 205 | 206 | for (const o of this.editor.engine.level_.objects!) { 207 | const size = sizes[o.type]; 208 | switch (o.type) { 209 | case "platform": 210 | case "platformH1": 211 | case "platformH2": 212 | case "platformV1": 213 | case "platformV2": 214 | case "platformB1": 215 | case "platformB2": 216 | this.ctx.fillStyle = "#ff02"; 217 | this.ctx.fillRect( 218 | o.pos.x - size[0], 219 | o.pos.y - size[1], 220 | size[0] * 2, 221 | size[1] * 2, 222 | ); 223 | break; 224 | case "savepoint": 225 | this.ctx.lineWidth = 1; 226 | this.ctx.strokeStyle = "blue"; 227 | this.ctx.beginPath(); 228 | this.ctx.moveTo(o.pos.x, 0); 229 | this.ctx.lineTo(o.pos.x, this.editor.engine.level_.size_.y); 230 | this.ctx.closePath(); 231 | this.ctx.stroke(); 232 | break; 233 | case "crystal": 234 | this.ctx.fillStyle = "#f005"; 235 | this.ctx.fillRect(o.pos.x - 10, o.pos.y - 10, 20, 20); 236 | break; 237 | case "gravityCrystal": 238 | this.ctx.fillStyle = "#f055"; 239 | this.ctx.fillRect(o.pos.x - 10, o.pos.y - 10, 20, 20); 240 | break; 241 | case "bubble": 242 | this.ctx.fillStyle = "#f0f5"; 243 | this.ctx.fillRect(o.pos.x - 25, o.pos.y - 25, 50, 50); 244 | break; 245 | } 246 | } 247 | } 248 | 249 | private renderLevelBoundaries() { 250 | this.ctx.lineWidth = 2; 251 | this.ctx.strokeStyle = "blue"; 252 | this.ctx.beginPath(); 253 | this.ctx.rect( 254 | 0, 255 | 0, 256 | this.engine.level_.size_.x, 257 | this.engine.level_.size_.y, 258 | ); 259 | this.ctx.closePath(); 260 | this.ctx.stroke(); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/editor/editor-ui.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorMode } from "./editor"; 2 | import { EDITOR_STYLES, EDITOR_HTML } from "./layout"; 3 | import { LevelSerializer } from "./serialization"; 4 | import { Listeners } from "./listeners"; 5 | import { LevelParser } from "../loader"; 6 | import { CanBeDeadly } from "../level.interface"; 7 | import { LEVELS } from "../levels"; 8 | import { Vector2 } from "../vector"; 9 | 10 | export class EditorUI { 11 | private listeners = new Listeners(); 12 | 13 | deadlyInputLabel: HTMLLabelElement; 14 | deadlyInput: HTMLInputElement; 15 | 16 | constructor(private editor: Editor) { 17 | this.renderHtml(); 18 | 19 | this.deadlyInputLabel = document.getElementById( 20 | "deadly-input-label", 21 | )! as HTMLLabelElement; 22 | this.deadlyInput = document.getElementById( 23 | "deadly-input", 24 | )! as HTMLInputElement; 25 | 26 | (document.getElementById( 27 | "level", 28 | ) as HTMLSelectElement).value = this.engine.currentSave.level_.toString(); 29 | 30 | this.hideDeadlyToggle(); 31 | 32 | this.listeners.listen("draw-colision-helpers", "change", event => { 33 | this.editor.drawColisionHelpers = (event.target as HTMLInputElement).checked; 34 | }); 35 | 36 | this.listeners.listen("draw-plants-helpers", "change", event => { 37 | this.editor.drawPlantsHelpers = (event.target as HTMLInputElement).checked; 38 | }); 39 | 40 | this.listeners.listen("toggle-pause", "click", () => { 41 | this.engine.game.stopped_ = !this.engine.game.stopped_; 42 | }); 43 | 44 | this.listeners.listen("save-game", "click", () => { 45 | this.engine.save_(); 46 | }); 47 | 48 | this.listeners.listen("clear-plants", "click", () => { 49 | this.engine.foliage.entities_ = []; 50 | }); 51 | 52 | this.listeners.listen("spawn-plants", "click", () => { 53 | this.engine.foliage.spawnFoliage(this.engine); 54 | }); 55 | 56 | this.listeners.listen("move-player", "click", () => { 57 | this.engine.player.body_.pos.x = 58 | this.engine.camera.pos.x + this.engine.canvas_.width / 2; 59 | this.engine.player.body_.pos.y = 60 | this.engine.camera.pos.y + this.engine.canvas_.height / 2 - 150; 61 | }); 62 | 63 | this.listeners.listen("get-player-position", "click", () => { 64 | console.log(this.engine.player.body_.pos); 65 | }); 66 | 67 | this.listeners.listen("deadly-input", "change", event => { 68 | for (const p of this.editor.manipulator.selectedPoints) { 69 | const object = this.editor.engine.level_.pointToCommandMap!.get(p); 70 | if (object) { 71 | object.isDeadly = (event.target as HTMLInputElement).checked; 72 | } 73 | } 74 | }); 75 | 76 | this.listeners.listen("regenerate-level", "click", () => { 77 | this.engine.physics.staticBodies = []; 78 | this.engine.physics.grid.clear(); 79 | const level = new LevelSerializer().serialize(this.engine.level_); 80 | new LevelParser(this.engine, level).parse_(); 81 | }); 82 | 83 | this.listeners.listen("generate-level-string", "click", () => { 84 | const levelString = new LevelSerializer().serialize( 85 | this.editor.engine.level_, 86 | ); 87 | const textarea = document.getElementById( 88 | "level-string", 89 | ) as HTMLTextAreaElement; 90 | textarea.value = levelString; 91 | }); 92 | 93 | this.listeners.listen("object-type", "change", event => { 94 | const objectToAdd = (event.target! as HTMLSelectElement).value; 95 | this.editor.manipulator.objectToAdd = objectToAdd as any; 96 | }); 97 | 98 | this.listeners.listen("level", "change", event => { 99 | const level = parseInt((event.target! as HTMLSelectElement).value); 100 | this.editor.engine.load_({ 101 | level_: level, 102 | pos: null, 103 | crystals: {}, 104 | }); 105 | 106 | // Resetting mode will rebind the camera to editor mode 107 | this.editor.setMode(this.editor.mode); 108 | }); 109 | 110 | this.listeners.listen("close-editor", "click", () => { 111 | this.editor.destroy(); 112 | this.listeners.clear(); 113 | document.getElementById("editor-css")!.remove(); 114 | document.getElementById("editor")!.remove(); 115 | }); 116 | 117 | document.querySelectorAll("[name=mode]").forEach(modeInput => 118 | this.listeners.listen(modeInput, "change", event => { 119 | const input = event.target as HTMLInputElement; 120 | if (input.checked) { 121 | this.editor.setMode(input.value as EditorMode); 122 | } 123 | }), 124 | ); 125 | } 126 | 127 | get engine() { 128 | return this.editor.engine; 129 | } 130 | 131 | clearObjectType() { 132 | const select = document.getElementById("object-type") as HTMLSelectElement; 133 | select.value = ""; 134 | } 135 | 136 | showDeadlyToggle(object: CanBeDeadly) { 137 | this.deadlyInput.checked = object.isDeadly; 138 | this.deadlyInputLabel.classList.remove("hidden"); 139 | } 140 | 141 | hideDeadlyToggle() { 142 | this.deadlyInputLabel.classList.add("hidden"); 143 | } 144 | 145 | private renderHtml() { 146 | const stylesEl = document.createElement("style") as any; 147 | stylesEl.id = "editor-css"; 148 | stylesEl.type = "text/css"; 149 | stylesEl.append(document.createTextNode(EDITOR_STYLES)); 150 | document.head.append(stylesEl); 151 | 152 | const editorEl = document.createElement("div"); 153 | editorEl.id = "editor"; 154 | editorEl.innerHTML = EDITOR_HTML; 155 | document.body.append(editorEl); 156 | 157 | this.renderLevelOptions(); 158 | } 159 | 160 | renderLevelOptions() { 161 | const levelEl = document.getElementById("level") as HTMLSelectElement; 162 | for (let i = 0; i < LEVELS.length; i++) { 163 | const option = document.createElement("option"); 164 | option.value = i.toString(); 165 | option.innerText = i.toString(); 166 | levelEl.append(option); 167 | } 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/editor/editor.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "../engine"; 2 | import { EditorRenderer } from "./editor-renderer"; 3 | import { Manipulator } from "./manipulator"; 4 | import { EditorUI } from "./editor-ui"; 5 | import { Vector2 } from "../vector"; 6 | import { EditorObjects } from "./objects"; 7 | 8 | export type EditorMode = "edit" | "play"; 9 | 10 | export class Editor { 11 | initialized = false; 12 | 13 | drawColisionHelpers = false; 14 | 15 | drawPlantsHelpers = false; 16 | 17 | mode: EditorMode = "edit"; 18 | 19 | originalRenderFn: () => void; 20 | originalCameraUpdateFn: () => void; 21 | 22 | editorRenderer = new EditorRenderer(this); 23 | 24 | editorObjects = new EditorObjects(this); 25 | 26 | manipulator: Manipulator; 27 | 28 | ui: EditorUI; 29 | 30 | controlsInterval: number; 31 | 32 | constructor(public engine: Engine) { 33 | this.originalCameraUpdateFn = this.engine.camera.update_; 34 | 35 | window.addEventListener("keydown", event => { 36 | if (event.key === "e" && !this.initialized) { 37 | this.init(); 38 | } 39 | 40 | if (event.key === "p") { 41 | this.engine.game.stopped_ = !this.engine.game.stopped_; 42 | } 43 | }); 44 | } 45 | 46 | init() { 47 | this.manipulator = new Manipulator(this); 48 | this.ui = new EditorUI(this); 49 | this.controlsInterval = window.setInterval( 50 | () => this.updateControls(), 51 | 17, 52 | ); 53 | 54 | this.setMode("edit"); 55 | 56 | document.getElementsByTagName("canvas")[0].classList.add("with-cursor"); 57 | 58 | const renderer = this.engine.renderer; 59 | this.originalRenderFn = renderer.render; 60 | renderer.render = () => { 61 | this.originalRenderFn.bind(renderer)(); 62 | this.editorRenderer.render(renderer.ctx); 63 | }; 64 | 65 | this.initialized = true; 66 | } 67 | 68 | destroy() { 69 | this.setMode("play"); 70 | this.engine.renderer.render = this.originalRenderFn; 71 | this.manipulator.destroy(); 72 | this.initialized = false; 73 | window.clearInterval(this.controlsInterval); 74 | } 75 | 76 | setMode(mode: EditorMode) { 77 | this.mode = mode; 78 | 79 | const editInput = document.querySelector( 80 | `[name=mode][value=${mode}]`, 81 | )! as HTMLInputElement; 82 | editInput.checked = true; 83 | 84 | switch (this.mode) { 85 | case "play": 86 | this.engine.camera.update_ = () => 87 | this.originalCameraUpdateFn.bind(this.engine.camera)(); 88 | break; 89 | case "edit": 90 | this.engine.camera.update_ = () => 91 | cameraUpdate.bind(this.engine.camera)(); 92 | break; 93 | } 94 | } 95 | 96 | updateControls() { 97 | if (this.initialized && this.mode !== "play") { 98 | const pos = this.engine.camera.pos; 99 | let speed = 10; 100 | if (this.engine.control_.keys_.get("ShiftLeft")) { 101 | speed = 30; 102 | } 103 | if (this.engine.control_.keys_.get("AltLeft")) { 104 | speed = 1; 105 | } 106 | if (this.engine.control_.keys_.get("KeyW")) { 107 | pos.y -= speed; 108 | this.manipulator.move(new Vector2(0, -speed)); 109 | } 110 | if (this.engine.control_.keys_.get("KeyS")) { 111 | pos.y += speed; 112 | this.manipulator.move(new Vector2(0, speed)); 113 | } 114 | if (this.engine.control_.keys_.get("KeyA")) { 115 | pos.x -= speed; 116 | this.manipulator.move(new Vector2(-speed, 0)); 117 | } 118 | if (this.engine.control_.keys_.get("KeyD")) { 119 | pos.x += speed; 120 | this.manipulator.move(new Vector2(speed, 0)); 121 | } 122 | } 123 | } 124 | } 125 | 126 | function cameraUpdate() {} 127 | -------------------------------------------------------------------------------- /src/editor/layout.ts: -------------------------------------------------------------------------------- 1 | export const EDITOR_STYLES = ` 2 | body { 3 | display: flex; 4 | background-color: #555; 5 | } 6 | 7 | canvas.with-cursor { 8 | cursor: initial; 9 | } 10 | 11 | #editor { 12 | display: flex; 13 | flex-direction: column; 14 | padding: 10px; 15 | color: white; 16 | min-width: 300px; 17 | background-color: #333; 18 | } 19 | 20 | #editor * { 21 | text-shadow: none; 22 | } 23 | 24 | .line { 25 | display: flex; 26 | flex-direction: row; 27 | } 28 | .line * { 29 | flex: 1; 30 | } 31 | 32 | .stack { 33 | display: flex; 34 | flex-direction: column; 35 | } 36 | 37 | .hidden { 38 | display: none; 39 | } 40 | 41 | .btn { 42 | margin-bottom: 0; 43 | border: 1px solid #333; 44 | padding: 5px; 45 | font-size: 15px; 46 | transition: none; 47 | } 48 | 49 | .btn:hover { 50 | font-size: 15px; 51 | background: #222; 52 | } 53 | 54 | #content { 55 | flex: 1; 56 | } 57 | 58 | #output-wrapper { 59 | display: flex; 60 | flex-direction: column; 61 | } 62 | `; 63 | 64 | export const EDITOR_HTML = ` 65 |

Editor

66 | 67 |
68 |
69 | 73 | 74 | 78 |
79 | 80 |
81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 |
89 | 90 |
91 | 92 | 93 |
94 | 95 | 96 |
97 | 98 | 99 |
100 | 101 | 105 | 106 | 124 | 125 | 129 |
130 | 131 | 137 | `; 138 | -------------------------------------------------------------------------------- /src/editor/listeners.ts: -------------------------------------------------------------------------------- 1 | export class Listeners { 2 | private listeners: [EventTarget, string, (event: Event) => void][] = []; 3 | 4 | listen( 5 | node: EventTarget | string, 6 | event: string, 7 | listener: (event: Event) => void, 8 | ) { 9 | if (typeof node === "string") { 10 | node = document.getElementById(node)!; 11 | } 12 | node.addEventListener(event, listener); 13 | this.listeners.push([node, event, listener]); 14 | } 15 | 16 | clear() { 17 | for (const [node, event, listener] of this.listeners) { 18 | node.removeEventListener(event, listener); 19 | } 20 | this.listeners = []; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/editor/manipulator.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "../vector"; 2 | import { Editor } from "./editor"; 3 | import { Listeners } from "./listeners"; 4 | import { ObjectType } from "./objects"; 5 | import { PathCommandType, PathCommand, CanBeDeadly } from "../level.interface"; 6 | 7 | export class Manipulator { 8 | focusedPoint: Vector2 | null; 9 | 10 | selectedPoints = new Set(); 11 | 12 | selectionArea: [Vector2, Vector2] | null = null; 13 | 14 | objectToAdd: ObjectType | "" = ""; 15 | 16 | private isMousePressed = false; 17 | 18 | private listeners = new Listeners(); 19 | 20 | constructor(public editor: Editor) { 21 | this.listeners.listen(window, "keydown", (event: KeyboardEvent) => { 22 | if (this.selectedPoints.size === 1) { 23 | const pathCommand = this.pointsMap!.get( 24 | Array.from(this.selectedPoints)[0], 25 | )!; 26 | if (event.key === "c") { 27 | this.cutAfterPoint(pathCommand); 28 | } 29 | if (event.key === "v") { 30 | this.togglePointBetweenBezierAndLine(pathCommand); 31 | } 32 | } 33 | 34 | if (event.key === "Delete") { 35 | for (const point of this.selectedPoints) { 36 | const object = this.pointsMap!.get(point)!; 37 | this.deleteObject(object); 38 | } 39 | } 40 | }); 41 | 42 | const canvas = this.editor.engine.canvas_; 43 | 44 | this.listeners.listen(canvas, "mousedown", (event: MouseEvent) => { 45 | const pos = this.mousePosToWorldPos(new Vector2(event.x, event.y)); 46 | 47 | if (this.objectToAdd !== "") { 48 | this.editor.editorObjects.place(this.objectToAdd, pos.copy()); 49 | this.objectToAdd = ""; 50 | this.editor.ui.clearObjectType(); 51 | return; 52 | } 53 | this.isMousePressed = true; 54 | this.selectionArea = null; 55 | if (this.focusedPoint) { 56 | if (!this.selectedPoints.has(this.focusedPoint)) { 57 | this.selectedPoints.clear(); 58 | this.selectedPoints.add(this.focusedPoint); 59 | } 60 | } else { 61 | this.selectedPoints.clear(); 62 | this.selectionArea = [pos, pos.copy()]; 63 | } 64 | }); 65 | 66 | this.listeners.listen(canvas, "mouseup", () => { 67 | this.isMousePressed = false; 68 | if (this.selectionArea) { 69 | const [f, t] = this.selectionArea; 70 | const [from, to] = [ 71 | new Vector2(Math.min(f.x, t.x), Math.min(f.y, t.y)), 72 | new Vector2(Math.max(f.x, t.x), Math.max(f.y, t.y)), 73 | ]; 74 | this.selectedPoints.clear(); 75 | for (const p of this.pointsMap!.keys()) { 76 | if (p.x > from.x && p.y > from.y && p.x < to.x && p.y < to.y) { 77 | this.selectedPoints.add(p); 78 | } 79 | } 80 | this.selectionArea = null; 81 | } 82 | if (this.selectedPoints.size === 1) { 83 | const p = Array.from(this.selectedPoints)[0]; 84 | const object = this.editor.engine.level_.pointToCommandMap!.get(p); 85 | this.editor.ui.showDeadlyToggle(object as CanBeDeadly); 86 | } else { 87 | this.editor.ui.hideDeadlyToggle(); 88 | } 89 | }); 90 | 91 | this.listeners.listen(canvas, "mousemove", (event: MouseEvent) => { 92 | this.focusedPoint = null; 93 | 94 | const diff = this.scalePosToWorld( 95 | new Vector2(event.movementX, event.movementY), 96 | ); 97 | 98 | this.move(diff); 99 | 100 | if (this.selectionArea) { 101 | const pos = this.mousePosToWorldPos(new Vector2(event.x, event.y)); 102 | this.selectionArea[1] = pos; 103 | } 104 | 105 | const pointerPos = this.mousePosToWorldPos( 106 | new Vector2(event.x, event.y), 107 | ); 108 | for (const p of this.pointsMap!.keys()) { 109 | if (p.distanceTo(pointerPos) < 5) { 110 | this.focusedPoint = p; 111 | } 112 | } 113 | }); 114 | } 115 | 116 | destroy() { 117 | this.listeners.clear(); 118 | } 119 | 120 | move(v: Vector2) { 121 | if (this.isMousePressed) { 122 | for (const point of this.selectedPoints) { 123 | point.add_(v); 124 | const pathCommand = this.pointsMap!.get(point)!; 125 | if ( 126 | this.selectedPoints.size === 1 && 127 | pathCommand.type === PathCommandType.bezier && 128 | point === pathCommand.points![2] 129 | ) { 130 | pathCommand.points![0].add_(v); 131 | pathCommand.points![1].add_(v); 132 | } 133 | } 134 | } 135 | } 136 | 137 | private cutAfterPoint(pathCommand: PathCommand) { 138 | const index = this.pathCommands.indexOf(pathCommand); 139 | if (index !== -1) { 140 | const nextPathCommand = this.pathCommands[index + 1]; 141 | if ( 142 | nextPathCommand.type === PathCommandType.line || 143 | nextPathCommand.type === PathCommandType.bezier 144 | ) { 145 | const from = pathCommand.points![0]; 146 | const to = nextPathCommand.points![0]; 147 | const diff = from.copy().sub_(to); 148 | const newPoint = from.copy().add_(diff.mul(-0.5)); 149 | const newCommand: PathCommand = { 150 | type: PathCommandType.line, 151 | points: [newPoint], 152 | isDeadly: pathCommand.isDeadly, 153 | }; 154 | this.pathCommands.splice(index + 1, 0, newCommand); 155 | this.pointsMap!.set(newPoint, newCommand); 156 | } 157 | } 158 | } 159 | 160 | private deleteObject(object: any) { 161 | let index = this.pathCommands.indexOf(object); 162 | if (index !== -1) { 163 | this.pathCommands.splice(index, 1); 164 | } 165 | 166 | index = this.pathCommands.indexOf(object); 167 | if (index !== -1) { 168 | this.pathCommands.splice(index, 1); 169 | } 170 | 171 | index = this.objects.indexOf(object); 172 | if (index !== -1) { 173 | this.objects.splice(index, 1); 174 | } 175 | } 176 | 177 | private togglePointBetweenBezierAndLine(pathCommand: PathCommand) { 178 | if (pathCommand.type === PathCommandType.line) { 179 | pathCommand.type = PathCommandType.bezier; 180 | const diff = new Vector2(10, 10); 181 | const to = pathCommand.points![0]; 182 | pathCommand.points = [to.copy().add_(diff), to.copy().sub_(diff), to]; 183 | this.pointsMap.set(pathCommand.points[0], pathCommand); 184 | this.pointsMap.set(pathCommand.points[1], pathCommand); 185 | } else if (pathCommand.type === PathCommandType.bezier) { 186 | pathCommand.type = PathCommandType.line; 187 | } 188 | } 189 | 190 | private mousePosToWorldPos(p: Vector2) { 191 | p = this.scalePosToWorld(p); 192 | const pos = this.editor.engine.camera.pos; 193 | return new Vector2(pos.x + p.x, pos.y + p.y); 194 | } 195 | 196 | private scalePosToWorld(p: Vector2) { 197 | const canvas = this.editor.engine.canvas_; 198 | const scale = canvas.width / canvas.clientWidth; 199 | return new Vector2(p.x * scale, p.y * scale); 200 | } 201 | 202 | get level() { 203 | return this.editor.engine.level_; 204 | } 205 | 206 | get pointsMap() { 207 | return this.level.pointToCommandMap!; 208 | } 209 | 210 | get pathCommands() { 211 | return this.level.pathCommands; 212 | } 213 | 214 | get objects() { 215 | return this.level.objects!; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/editor/objects.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "../vector"; 2 | import { Editor } from "./editor"; 3 | import { 4 | PathCommand, 5 | PathCommandType, 6 | LevelObject, 7 | PickableType, 8 | } from "../level.interface"; 9 | import { stringify } from "querystring"; 10 | 11 | export type ObjectType = 12 | | "polygon" 13 | | "platform" 14 | | "platformH1" 15 | | "platformH2" 16 | | "platformV1" 17 | | "platformV2" 18 | | "platformB1" 19 | | "platformB2" 20 | | "savepoint" 21 | | "crystal" 22 | | "gravityCrystal" 23 | | "bubble"; 24 | 25 | export class EditorObjects { 26 | constructor(private editor: Editor) {} 27 | 28 | place(type: ObjectType, pos: Vector2) { 29 | switch (type) { 30 | case "polygon": 31 | this.createPolygon(pos); 32 | break; 33 | case "platform": 34 | case "platformH1": 35 | case "platformH2": 36 | case "platformV1": 37 | case "platformV2": 38 | case "platformB1": 39 | case "platformB2": 40 | const platform: LevelObject = { type, pos, isDeadly: false }; 41 | this.editor.engine.level_.objects!.push(platform); 42 | this.pointsMap.set(pos, platform as any); 43 | break; 44 | case "savepoint": 45 | const savepoint: LevelObject = { 46 | type, 47 | pos, 48 | isDeadly: false, 49 | }; 50 | this.editor.engine.level_.savepoints.push(pos.x); 51 | this.editor.engine.level_.objects!.push(savepoint); 52 | this.pointsMap.set(pos, savepoint as any); 53 | break; 54 | case "crystal": 55 | case "gravityCrystal": 56 | case "bubble": 57 | const typeMap = new Map([ 58 | ["crystal", PickableType.crystal], 59 | ["gravityCrystal", PickableType.gravityCrystal], 60 | ["bubble", PickableType.bubble], 61 | ]); 62 | const pickable: LevelObject = { 63 | type, 64 | pos, 65 | isDeadly: false, 66 | }; 67 | this.editor.engine.level_.pickables.push({ 68 | type: typeMap.get(type)!, 69 | collected: false, 70 | pos, 71 | radius: 25, 72 | }); 73 | this.editor.engine.level_.objects!.push(pickable); 74 | this.pointsMap.set(pos, pickable as any); 75 | break; 76 | } 77 | } 78 | 79 | private createPolygon(pos: Vector2) { 80 | const commands = this.editor.engine.level_.pathCommands; 81 | 82 | let to = pos.copy(); 83 | let command: PathCommand = { 84 | type: PathCommandType.move, 85 | points: [to], 86 | isDeadly: false, 87 | }; 88 | commands.push(command); 89 | this.pointsMap.set(to, command); 90 | 91 | to = new Vector2(50, 0).add_(pos); 92 | command = { type: PathCommandType.line, points: [to], isDeadly: false }; 93 | commands.push(command); 94 | this.pointsMap.set(to, command); 95 | 96 | to = new Vector2(50, 50).add_(pos); 97 | command = { type: PathCommandType.line, points: [to], isDeadly: false }; 98 | commands.push(command); 99 | this.pointsMap.set(to, command); 100 | 101 | to = new Vector2(0, 50).add_(pos); 102 | command = { type: PathCommandType.line, points: [to], isDeadly: false }; 103 | commands.push(command); 104 | this.pointsMap.set(to, command); 105 | 106 | commands.push({ type: PathCommandType.close, isDeadly: false }); 107 | } 108 | 109 | get pointsMap() { 110 | return this.editor.engine.level_.pointToCommandMap!; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/editor/serialization.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "../vector"; 2 | import { Level, PathCommandType, CanBeDeadly } from "../level.interface"; 3 | 4 | const COMMAND_MAP = { 5 | [PathCommandType.move]: "m", 6 | [PathCommandType.line]: "l", 7 | [PathCommandType.bezier]: "c", 8 | [PathCommandType.close]: "c", 9 | }; 10 | 11 | export class LevelSerializer { 12 | serialize(level: Level): string { 13 | const tokens: string[] = [ 14 | this.serializeVector(level.size_), 15 | this.serializeNumber(level.startingPos), 16 | ]; 17 | 18 | let localPos = new Vector2(); 19 | let to: Vector2; 20 | let lastCommand = "m"; 21 | let isDeadly = false; 22 | for (const pathCommand of level.pathCommands!) { 23 | const command = COMMAND_MAP[pathCommand.type]; 24 | if (pathCommand.isDeadly !== isDeadly) { 25 | tokens.push("d"); 26 | isDeadly = pathCommand.isDeadly; 27 | } 28 | if (lastCommand !== command) { 29 | tokens.push(command); 30 | lastCommand = command; 31 | } 32 | switch (pathCommand.type) { 33 | case PathCommandType.move: 34 | localPos = pathCommand.points![0]; 35 | tokens.push(this.serializeVector(localPos)); 36 | break; 37 | case PathCommandType.line: 38 | to = pathCommand.points![0].copy(); 39 | tokens.push(this.serializeVector(to.copy().sub_(localPos))); 40 | localPos = to.copy(); 41 | break; 42 | case PathCommandType.bezier: 43 | const [c1, c2, to_] = pathCommand.points!; 44 | 45 | tokens.push(this.serializeVector(c1.copy().sub_(localPos))); 46 | tokens.push(this.serializeVector(c2.copy().sub_(localPos))); 47 | tokens.push(this.serializeVector(to_.copy().sub_(localPos))); 48 | localPos = to_.copy(); 49 | break; 50 | case PathCommandType.close: 51 | tokens.push("z"); 52 | } 53 | } 54 | 55 | const objects = level.objects!.sort((a, b) => 56 | a.type.localeCompare(b.type), 57 | ); 58 | let serializingPlatforms = false; 59 | for (const o of objects) { 60 | if (o.isDeadly != isDeadly) { 61 | tokens.push("d"); 62 | isDeadly = o.isDeadly; 63 | } 64 | if (o.type.startsWith("platform") && !serializingPlatforms) { 65 | serializingPlatforms = true; 66 | tokens.push("p"); 67 | } 68 | switch (o.type) { 69 | case "platform": 70 | tokens.push("P"); 71 | tokens.push(this.serializeVector(o.pos)); 72 | break; 73 | case "platformH1": 74 | tokens.push("h"); 75 | tokens.push(this.serializeVector(o.pos)); 76 | break; 77 | case "platformH2": 78 | tokens.push("H"); 79 | tokens.push(this.serializeVector(o.pos)); 80 | break; 81 | case "platformV1": 82 | tokens.push("v"); 83 | tokens.push(this.serializeVector(o.pos)); 84 | break; 85 | case "platformV2": 86 | tokens.push("V"); 87 | tokens.push(this.serializeVector(o.pos)); 88 | break; 89 | case "platformB1": 90 | tokens.push("b"); 91 | tokens.push(this.serializeVector(o.pos)); 92 | break; 93 | case "platformB2": 94 | tokens.push("M"); 95 | tokens.push(this.serializeVector(o.pos)); 96 | break; 97 | case "savepoint": 98 | tokens.push("s"); 99 | tokens.push(this.serializeNumber(o.pos.x)); 100 | break; 101 | case "crystal": 102 | tokens.push("C"); 103 | tokens.push(this.serializeVector(o.pos)); 104 | break; 105 | case "gravityCrystal": 106 | tokens.push("G"); 107 | tokens.push(this.serializeVector(o.pos)); 108 | break; 109 | case "bubble": 110 | tokens.push("B"); 111 | tokens.push(this.serializeVector(o.pos)); 112 | break; 113 | } 114 | } 115 | 116 | for (let i = 0; i < tokens.length - 1; i++) { 117 | if ( 118 | this.isLastCharADigit(tokens[i]) && 119 | this.isFirstCharADigit(tokens[i + 1]) 120 | ) { 121 | tokens[i] += " "; 122 | } 123 | } 124 | return tokens.join(""); 125 | } 126 | 127 | private isLastCharADigit(s: string) { 128 | const c = s[s.length - 1]; 129 | return c >= "0" && c <= "9"; 130 | } 131 | 132 | private isFirstCharADigit(s: string) { 133 | const c = s[0]; 134 | return c >= "0" && c <= "9"; 135 | } 136 | 137 | private serializeNumber(x: number) { 138 | return (x / 10).toFixed(0); 139 | } 140 | 141 | private serializeVector(v: Vector2) { 142 | const x = this.serializeNumber(v.x); 143 | const y = this.serializeNumber(v.y); 144 | const sep = y[0] === "-" ? "" : " "; 145 | return x + sep + y; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/engine.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "./game"; 2 | import { PhysicsSystem } from "./physics/physics"; 3 | import { FoliageSystem } from "./foliage"; 4 | import { ParticlesSystem } from "./particles"; 5 | import { Player } from "./player"; 6 | import { Vector2 } from "./vector"; 7 | import { Control } from "./control"; 8 | import { Renderer } from "./renderer/renderer"; 9 | import { Camera } from "./camera"; 10 | import { Level } from "./level.interface"; 11 | import { Save, save_ } from "./saves"; 12 | import { loadLevel } from "./loader"; 13 | import { LEVELS } from "./levels"; 14 | 15 | // #if process.env.NODE_ENV === 'development' 16 | import { Editor } from "./editor/editor"; 17 | // #endif 18 | 19 | export class Engine { 20 | time_ = 0; 21 | 22 | // used to calculate how much time it took the player to beat the game 23 | gameTime = parseInt(localStorage.getItem("tww_t")!) || 0; 24 | 25 | physics = new PhysicsSystem(); 26 | 27 | foliage = new FoliageSystem(); 28 | 29 | particles = new ParticlesSystem(this); 30 | 31 | control_ = new Control(this.game); 32 | 33 | renderer = new Renderer(this); 34 | 35 | camera = new Camera(this); 36 | 37 | player: Player; 38 | 39 | level_: Level; 40 | 41 | currentSave: Save; 42 | 43 | levelTransitionEnter = 0; 44 | 45 | levelTransitionLeave = 0; 46 | 47 | // #if process.env.NODE_ENV === 'development' 48 | editor = new Editor(this); 49 | // #endif 50 | 51 | constructor(public game: Game, public canvas_: HTMLCanvasElement) { 52 | this.control_.init(); 53 | } 54 | 55 | load_(save: Save) { 56 | this.physics.clear_(); 57 | 58 | this.currentSave = save; 59 | loadLevel(this, save.level_); 60 | this.respawnPlayer(); 61 | this.renderer.init(); 62 | this.foliage.spawnFoliage(this); 63 | } 64 | 65 | respawnPlayer() { 66 | this.fixupPlayerPosition(); 67 | this.player = new Player( 68 | this, 69 | new Vector2(this.currentSave.pos!.x, this.currentSave.pos!.y), 70 | ); 71 | } 72 | 73 | fixupPlayerPosition() { 74 | const startingPos = new Vector2(150, this.level_.startingPos); 75 | if (!this.currentSave.pos) { 76 | this.currentSave.pos = startingPos; 77 | } 78 | 79 | const pos = this.physics.castRay( 80 | new Vector2(this.currentSave.pos.x, this.currentSave.pos.y), 81 | new Vector2(this.currentSave.pos.x, this.level_.size_.y), 82 | ); 83 | 84 | if (pos) { 85 | this.currentSave.pos.y = pos.y - 10; 86 | return; 87 | } 88 | 89 | this.currentSave.pos = startingPos; 90 | } 91 | 92 | save_() { 93 | this.currentSave.pos = this.player.body_.pos.copy(); 94 | save_(this.currentSave); 95 | this.saveGameTime(); 96 | } 97 | 98 | saveGameTime() { 99 | localStorage.setItem("tww_t", this.gameTime.toString()); 100 | } 101 | 102 | update_(timeStep: number) { 103 | this.time_ += timeStep; 104 | this.particles.update_(); 105 | 106 | if (this.levelTransitionLeave) { 107 | return; 108 | } 109 | 110 | this.player.update_(); 111 | 112 | if (this.game.stopped_) { 113 | return; 114 | } 115 | 116 | this.gameTime += timeStep; 117 | 118 | const playerPos = this.player.body_.pos; 119 | for (const savepoint of this.level_.savepoints) { 120 | if (savepoint > this.currentSave.pos!.x && playerPos.x > savepoint) { 121 | this.save_(); 122 | } 123 | } 124 | 125 | if (playerPos.x > this.level_.size_.x + 10) { 126 | if (this.currentSave.level_ === LEVELS.length - 1) { 127 | this.saveGameTime(); 128 | this.game.menu.finish(this.currentSave); 129 | this.game.stopped_ = true; 130 | return; 131 | } 132 | this.levelTransitionLeave = this.time_; 133 | setTimeout(() => { 134 | this.levelTransitionLeave = 0; 135 | 136 | this.currentSave.level_++; 137 | this.currentSave.pos = null; 138 | save_(this.currentSave); 139 | this.load_(this.currentSave); 140 | 141 | this.levelTransitionEnter = this.time_; 142 | setTimeout(() => { 143 | this.levelTransitionEnter = 0; 144 | }, 1100); 145 | }, 1100); 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/foliage.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "./engine"; 2 | 3 | import { Vector2 } from "./vector"; 4 | import { Random } from "./random"; 5 | import { PlantDefinition } from "./plants"; 6 | import { assets } from "./assets"; 7 | import { lineToPointColision, getLineCells } from "./physics/shapes"; 8 | import { StaticBody } from "./physics/physics"; 9 | import { TREE_GROUND_MASK } from "./colisions-masks"; 10 | 11 | interface Foliage { 12 | pos: Vector2; 13 | definition: PlantDefinition; 14 | isForeground: boolean; 15 | } 16 | 17 | export class FoliageSystem { 18 | GRID_SIZE = 100; 19 | entities_: Foliage[][]; 20 | 21 | async spawnFoliage(engine: Engine) { 22 | this.entities_ = []; 23 | for (let x = 0; x <= engine.level_.size_.x * 2; x += this.GRID_SIZE) { 24 | this.entities_.push([]); 25 | } 26 | 27 | const r = new Random(engine.currentSave.level_ + 2); 28 | 29 | for (const treeDefinition of assets.plants) { 30 | let x = r.nextFloat() * treeDefinition.spread; 31 | while (x < engine.level_.size_.x) { 32 | x += 33 | treeDefinition.spread + 34 | treeDefinition.spread * (r.nextFloat() - 0.5); 35 | const cell = this.entities_[Math.floor(x / this.GRID_SIZE)]; 36 | const positions = this.findGround(engine, x, treeDefinition.mask_); 37 | for (const pos of positions) { 38 | if (pos) { 39 | const isForeground = r.nextFloat() > 0.8; 40 | cell.push({ 41 | pos: pos.add_( 42 | new Vector2(0, (isForeground ? 5 : 0) + r.nextFloat() * 5), 43 | ), 44 | definition: treeDefinition, 45 | isForeground, 46 | }); 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | *findGround(engine: Engine, x: number, hitMask: number) { 54 | let requiredSpace = hitMask === TREE_GROUND_MASK ? 200 : 30; 55 | 56 | const cells = getLineCells( 57 | new Vector2(x, -1000), 58 | new Vector2(x, engine.level_.size_.y), 59 | ); 60 | 61 | const linesToCheck = new Set<[Vector2, Vector2, boolean]>(); 62 | const grid = engine.physics.grid; 63 | const checked = new Set(); 64 | for (const cell of cells) { 65 | if (grid.has(cell)) { 66 | for (const body of grid.get(cell)!) { 67 | if (!checked.has(body)) { 68 | checked.add(body); 69 | linesToCheck.add([ 70 | body.start_, 71 | body.end_, 72 | !!(body.receiveMask & hitMask), 73 | ]); 74 | } 75 | } 76 | } 77 | } 78 | 79 | let narrowChecks: [Vector2, Vector2, Vector2, number, boolean][] = []; 80 | for (const [start_, end_, shouldGenerate] of linesToCheck) { 81 | const d = end_.copy().sub_(start_); 82 | if (!d.x) { 83 | continue; 84 | } 85 | const slope = d.y / d.x; 86 | 87 | const b = start_.y - slope * start_.x; 88 | const crossPoint = new Vector2(x, slope * x + b); 89 | narrowChecks.push([start_, end_, crossPoint, slope, shouldGenerate]); 90 | } 91 | 92 | narrowChecks.sort((checkA, checkB) => checkA[2].y - checkB[2].y); 93 | 94 | let add = true; 95 | let previousY = 0; 96 | for (const [ 97 | start_, 98 | end_, 99 | crossPoint, 100 | slope, 101 | shouldGenerate, 102 | ] of narrowChecks) { 103 | if (lineToPointColision(start_, end_, crossPoint)) { 104 | if ( 105 | shouldGenerate && 106 | crossPoint.y - previousY > requiredSpace && 107 | Math.abs(slope) < 1.5 && 108 | add 109 | ) { 110 | yield crossPoint; 111 | } 112 | previousY = crossPoint.y; 113 | add = !add; 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/game.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "./engine"; 2 | import { loadSave, clearSave } from "./saves"; 3 | import { Menu, MenuMode } from "./menu"; 4 | 5 | export class Game { 6 | stopped_ = true; 7 | 8 | engine!: Engine; 9 | 10 | menu: Menu; 11 | 12 | constructor(canvas_: HTMLCanvasElement) { 13 | this.engine = new Engine(this, canvas_); 14 | this.menu = new Menu(this); 15 | } 16 | 17 | start() { 18 | this.engine.load_(loadSave()); 19 | } 20 | 21 | togglePause() { 22 | this.stopped_ = !this.stopped_; 23 | this.stopped_ ? this.menu.show() : this.menu.hide(); 24 | } 25 | 26 | startNewGame() { 27 | this.menu.mode = MenuMode.menu; 28 | localStorage.removeItem("tww_d"); // clear deaths count 29 | localStorage.removeItem("tww_t"); // clear stopwatch 30 | this.engine.gameTime = 0; 31 | clearSave(); 32 | this.start(); 33 | this.stopped_ = false; 34 | this.menu.hide(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 94 | 95 | 96 | 97 | 98 | The Wandering Wraith 99 | ...or the grand venture of going back to the grave. 100 | Loading... 101 |
102 | 103 | 104 |
105 | 106 |
107 |

THE END

108 | The wraith found his peace back in the grave. 109 | Time: 110 | Collected /75 crystals 111 | Died times 112 | Press ESC to continue 113 |
114 | 115 |
116 |

A js13k challenge by

117 |

Mateusz Tomczyk

118 |

Thanks to Frank Force for ZzFX

119 | Press ESC to continue 120 |
121 |
122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { prepareAssets } from "./assets"; 2 | import { Game } from "./game"; 3 | import { playMusic } from "./music"; 4 | 5 | let cumulativeTime = 0; 6 | const timeStep = 1000 / 60; 7 | 8 | let game: Game; 9 | 10 | async function init() { 11 | await prepareAssets(); 12 | 13 | const canvas = document.getElementsByTagName("canvas")[0]; 14 | 15 | game = new Game(canvas); 16 | game.start(); 17 | 18 | requestAnimationFrame(tick); 19 | 20 | window.addEventListener("resize", () => game.engine.renderer.updateSize()); 21 | } 22 | 23 | export function tick(timestamp: number) { 24 | const timeDiff = timestamp - cumulativeTime; 25 | const steps = Math.floor(timeDiff / timeStep); 26 | cumulativeTime += steps * timeStep; 27 | 28 | for (let i = 0; i < steps; i++) { 29 | game.engine.update_(timeStep); 30 | } 31 | game.engine.camera.update_(); 32 | game.engine.renderer.render(); 33 | requestAnimationFrame(tick); 34 | playMusic(cumulativeTime); 35 | } 36 | 37 | init(); 38 | -------------------------------------------------------------------------------- /src/level.interface.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | import { ObjectType } from "./editor/objects"; 3 | 4 | export const enum PickableType { 5 | crystal, 6 | gravityCrystal, 7 | bubble, 8 | } 9 | 10 | export const enum PathCommandType { 11 | move, 12 | line, 13 | bezier, 14 | close, 15 | } 16 | 17 | export interface CanBeDeadly { 18 | isDeadly: boolean; 19 | } 20 | 21 | export interface Pickable { 22 | type: PickableType; 23 | pos: Vector2; 24 | collected: boolean; 25 | radius: number; 26 | } 27 | 28 | export interface PathCommand extends CanBeDeadly { 29 | type: PathCommandType; 30 | points?: Vector2[]; 31 | } 32 | 33 | export interface Platform extends CanBeDeadly { 34 | x: number; 35 | y: number; 36 | w: number; 37 | h: number; 38 | } 39 | 40 | export interface Level { 41 | size_: Vector2; 42 | startingPos: number; // y coordinate 43 | pathCommands: PathCommand[]; 44 | platforms: Platform[]; 45 | savepoints: number[]; 46 | pickables: Pickable[]; 47 | 48 | // Below are properties present only in development build. 49 | // Needed for editor to work. 50 | 51 | // TODO Should be more general then PathCommand 52 | pointToCommandMap?: Map; 53 | 54 | objects?: LevelObject[]; 55 | } 56 | 57 | // Development only interface needed for editor. 58 | export interface LevelObject extends CanBeDeadly { 59 | type: ObjectType; 60 | pos: Vector2; 61 | } 62 | -------------------------------------------------------------------------------- /src/levels.ts: -------------------------------------------------------------------------------- 1 | export const LEVELS = [ 2 | "400 70 0-2-6l14 1c12 5-19 13-8 34 25 3 25 6 48 1l15-2 0-3c6 0 12 4 32-1 2 4-4 5 0 11dl7 0dc4-8-3-4 0-11 9-4 14 0 23-1-1 8-6 8-3 13d13-5 7 4 21-1d2-5-4-5-1-10 17 4 78-1 81 2l0 13dc19 0 11 18 33 15d48 2 57-6 102 0l0-11c10-1 23 3 43 5l-1 21-406 1czm253 26c10 2 16-6 18-2l0 7 9 0 0 9 10 0 0 10-18 0-19-15czC138 16C314 20C251 29C228 32C184 8C350 24dpv318 43dv309 43v165 24v184 18v350 36V318 31dV309 31dV217 21s158s295s91s347s223", 3 | "200 90 0-1 71c28-4 63 7 102 1dl0-26d0-2c32 7 26-4 100 0l0 47-202 0czm103 18c6 3 8-1 14 2 0 4 4 5 1 12l-14-6czB95 65C40 57C54 47C45 53C134 15pM158 39h42 60H47 50v42 55v131 18s83", 4 | "310 130 0 209 131dl-1-41d0-2c27 6 48-8 19-10l-27-1d0-9dc27-1 18-9 71-14l0-11c30 4 23-1 40 0l0 88czm135 86c7 0 14-1 25 3l-6 4-19 0czm262 18l3 0c-2 7 4 9 0 20l-3 0czm-1 99c8 4 15-2 30 2l0 30-31 0czB40 105B83 90B111 89B181 84B201 104B83 69B112 68B98 78C97 49C148 84C97 69C235 83C284 16C120 34C70 35dpP266 27db240 56h284 19V171 87s152s252", 5 | "320 70 50-1 52c29 5 57-3 67 0d45-13 95 12 113-18d23 5 28 0 45 2 0 5-3 9-1 15d6 3 10-1 14 0d3-8-1-12 0-17 35-3 65 2 83 1l0 36-322 0czdm156-1l0 31dc-52-4-74 0-87 4-1-3 0-5 1-8d-5 1-9-2-15 0d-1 5 4 6 2 8l-20-1dc-24-3-36-17-36-35dzm288-1l0 3-7 0 0 4 7 0c0 7-2 11-7 11-5 0-7-4-14-18zB134 46C63 35C96 43C283 21C283 5C297 15C160 14C237 44C224 47G44 50G102 37dpP236 37P224 42db220 33M243 29h281 24h158 16H96 40dH296 1dv81 35dv113 30dv295 14v281 19V127 33dV303 10ds194", 6 | "400 120 0-1 59c19 1 28 8 50 2l51 1 1 33d11 0d0-28c44-5 42 5 83 4 5-19-18-25-17-34 18 4 19-2 26 0l-1 26 32 2dc9 12 0 19 34 20 25 0 41-3 40-14d-2-8-66-7-70-15l3-14c13 5 19-3 24 1 9 6 13-1 22-2 16-2 14 4 31 1d31 9 35 10 59 3dl24 0 0 76-402 0czm335 33l-10 3 0-9c13-6 16-2 22-2 20 1 35 24-1 8zm47 37c11-2 27-1 28 0l-28 6czB181 65C299 72C379 13C67 23C69 43C110 71C110 90pP252 76P266 79P291 78P255 76P294 78P341 21P347 17P353 13dP60 22dP36 50dP102 85h365 20h103 63h110 76H376 8dv317 43dv274 75dv281 81v293 68v366 31v21 53v81 43dV287 79dV254 61dV378 40dV376 17dV361 23dV62 29s311s352s230s159s94", 7 | "240 100-1-1 18c15 3 20-1 28 0-14 54-8 50 8 51d24-11 35-4 36-1 9 23 17 21 40 21 57-10 91-24 90-38-2-12 5-23 40-25dl0 75-242 0czm50-1dl191 0d0 11c-11 2-51 4-52 12-3 20-21 18-27 27-14 15-24 15-39 16-23 3-49-16-66-26zB38 55B75 59B104 81B141 67B197 28B177 63C38 33C38 43C38 23C104 66ph105 69dH195 44dv211 17v211 33dV100 62V152 74", 8 | "400 80 65-1 68l28 0dc8-18 14-25 26-21 29 11 16 27 27 28 51 0 35-7 58-22 11-9 6-23 29-13dl35 0 0 38 32 0dc4-4 30-3 34-5 8-7 6-16 9-27 2-7-14-14-8-20 5-6 24 16 37 18 44 4 43-12 62-18 14-2 20 19 34 22dl0 32-402 0czm-1-1dl402 0d0 32c-19-4-26-24-39-21-15 5-7 17-19 21-28 8-33-5-68-16-21-8-23 1-23 20l0 27-35 0-1-51c-43 5-57-8-76 8-18 17-23 39-30 39-41-6-36-24-54-27-24-1-35 18-39 18l-18 0czB262 19B261 51B135 41B57 41B25 61B121 60B295 32B333 37B363 21B87 61C168 15C146 18C168 21C187 18G185 35G227 71dpP203 62h203 43dh168 18h187 21dH215 54v241 63dV158 18dV201 32V173 25ds177s219", 9 | "250 120 110-1 114l32-1c-7-29 6-31 2-59l37 0-8 59 55 0dc45-13 52-19 55-28 2-5-9-8-7-17dl0-2 18 0c-6 14 2 14-1 22 23-5 35 2 49 0d-13-39-1-49-3-57dl23-1 0 91-251 0czm-1-1l61 1dc-31 11-42 52-43 57d-1 28-10 24-12 42l-6 0czdm84 101dl0-52c25 3 42 14 66 8l0 2dc-1 4 3 6 5 18d-42 3-37-12-52-3-9 6-4 9-11 27zB17 107B143 87B102 97B220 79C18 70C10 86C95 103C155 41C108 76C195 20dpP71 57dP195 30h26 92dh81 65h71 75h81 84H17 82H71 96dH105 79dv23 97dv91 105v114 80dv33 68ds42s177", 10 | "250 120 50 0 78l34 0dc1 21 27 15 64 14 18-1 7-36 24-45d25 8 50 14 129 13l0 60-251 0czdm29 68l-8-58dc35 2 59 16 59 28d-25-7-27 11-22 14 12 4 16-3 32-2-5 10 0 8-3 18dzdm132-2dl77 1 8 27c-36 2-64 5-85 5zB44 85B92 71C63 89C63 46C44 4C210 28C131 4C225 40G71 84G135 49G171 31G197 29pP63 48P108 38dP200 39P132 8dh63 92h71 82h175 41dH124-1H108-1dv78 49v92 37v154 32dV58 86V76 82dV175 32V202 33s127", 11 | "300 100 0-1 81c38 1 44-4 103 0l0-16c31 2 50-4 81-2l0 2d0 31 16 0 0-25d0-2 30 0 0-8 10 0 0-8 11 0 0 16d26 0d26-2 0 34-301-1czm173-1l128 0 0 44dc-72-8-66 10-128 0dzdm143-1dc4 5-4 15-1 32d-16 5-21-2-43 2d-32-8-37 3-51-1d-3-8 5-23 3-33dzC37 37C86 42C95 45C57 46C130 36C189 91C189 73C252 61C172 4C145 6G59 57G119 62G193 88dpP172 10db28 69M27 77M34 77h39 34h60 50h122 48dh92 57dH83 39H59 61H82 57dH144 46H151-1H166-1dv34 53dv34 45dv34 37dv26 53dv26 45dv26 37v74 42dv196 86dv50 61V49 39V90 48V135 39V125 39V260 44V99 37ds108s176s224", 12 | "300 100 0-1 17c8 5 14-2 28 0l0 4dc-9 8-8 65-7 80dl-21 0czdm57 101c9-43 8-65-1-72dl0-8c16-2 18 3 26 0l0 6c-5 16-1 63 0 73zm115 101dc5-32 5-45 0-53dl0-10c7 0 11-2 16 0l0 10dc-7 8 0 30 0 53dzm301 101l-16 0c2-46-9-45-8-52 15 2 21-1 24 0zm214 101dc1-46-14-68 5-68 23 0 2 22 2 68dzB129 31B51 86B54 58B231 21B158 30B166 60B191 26B164 85C26 60C35 47C53 6C34 67C30 34C81 45C81 63C83 84C113 87C115 69C115 59C220 23C177 77C147 78G97 88dpP26 56P35 43dP30 79P36 84dP34 63P80 52h80 38h82 74dh117 56h117 66h115 84dH99 79dv27 33v37 46v24 59v36 66dV215 29V225 29ds74s123", 13 | "150 50 0-1 30c21 1 32-5 43-2 24 6 30-3 61 3l69 0 0 21-173 0czpv103 30", 14 | ]; 15 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "./engine"; 2 | import { Vector2 } from "./vector"; 3 | import { generateBezierSegments } from "./bezier"; 4 | import { GROUND_MASK, TREE_GROUND_MASK, GRASS_MASK } from "./colisions-masks"; 5 | import { LEVELS } from "./levels"; 6 | import { 7 | PathCommandType, 8 | PathCommand, 9 | LevelObject, 10 | Platform, 11 | Pickable, 12 | PickableType, 13 | } from "./level.interface"; 14 | import { ObjectType } from "./editor/objects"; 15 | 16 | // m - move to, (x y) 17 | // l - line to, (x y)+ 18 | // c - cubic bezier, (x1 y1 x2 y2 x y)+ 19 | // z - close path 20 | // p - platform (x, y) 21 | // d - toggling is deadly flag 22 | // s - savepoint (x) 23 | // C - crystal (x,y) 24 | // G - gravity crystal (x,y) 25 | // B - bubble (x,y) 26 | const commands = "mlczpdsCGB"; 27 | 28 | export function loadLevel(engine: Engine, level: number) { 29 | const levelDef = LEVELS[level]; 30 | new LevelParser(engine, levelDef).parse_(); 31 | } 32 | 33 | export class LevelParser { 34 | private pos: Vector2; 35 | private index_ = 0; 36 | 37 | constructor(private engine: Engine, private d: string) {} 38 | 39 | parse_() { 40 | this.next(); 41 | const pathCommands: PathCommand[] = []; 42 | const platforms: Platform[] = []; 43 | const savepoints: number[] = []; 44 | const pickables: Pickable[] = []; 45 | this.engine.level_ = { 46 | size_: this.parseVector(), 47 | startingPos: this.parseNumber(), 48 | pathCommands, 49 | platforms, 50 | savepoints, 51 | pickables: pickables, 52 | }; 53 | 54 | // #if process.env.NODE_ENV === 'development' 55 | const pointsMap = new Map(); 56 | this.engine.level_.pointToCommandMap = pointsMap; 57 | 58 | const objects: LevelObject[] = []; 59 | this.engine.level_.objects = objects; 60 | // #endif 61 | 62 | let command = "m"; 63 | let firstPoint: Vector2 | null = null; 64 | let c = this.d[this.index_ - 1]; 65 | let isDeadly = false; 66 | while (c) { 67 | if (commands.includes(c)) { 68 | if (c === "d") { 69 | isDeadly = !isDeadly; 70 | c = this.next(); 71 | continue; 72 | } 73 | command = c; 74 | if (command === "z") { 75 | pathCommands.push({ type: PathCommandType.close, isDeadly: false }); 76 | this.addStatic(this.pos, firstPoint!, isDeadly); 77 | } 78 | c = this.next(); 79 | continue; 80 | } 81 | if (c === " ") { 82 | c = this.next(); 83 | continue; 84 | } 85 | let points: Vector2[] = []; 86 | let pos: Vector2; 87 | switch (command) { 88 | case "m": 89 | this.pos = this.parseVector(); 90 | firstPoint = this.pos.copy(); 91 | pathCommands.push({ 92 | type: PathCommandType.move, 93 | points: [firstPoint], 94 | isDeadly, 95 | }); 96 | // #if process.env.NODE_ENV === 'development' 97 | pointsMap.set(firstPoint, pathCommands[pathCommands.length - 1]); 98 | // #endif 99 | command = "l"; 100 | break; 101 | case "l": 102 | points = [this.pos.copy(), this.pos.add_(this.parseVector()).copy()]; 103 | pathCommands.push({ 104 | type: PathCommandType.line, 105 | points: [points[1]], 106 | isDeadly, 107 | }); 108 | this.addStatic( 109 | points[0], 110 | points[1], 111 | isDeadly, 112 | GRASS_MASK | TREE_GROUND_MASK, 113 | ); 114 | // #if process.env.NODE_ENV === 'development' 115 | pointsMap.set(points[1], pathCommands[pathCommands.length - 1]); 116 | // #endif 117 | break; 118 | case "c": 119 | const oldPos = this.pos.copy(); 120 | points = [ 121 | this.pos.copy().add_(this.parseVector()), 122 | this.pos.copy().add_(this.parseVector()), 123 | this.pos.add_(this.parseVector()).copy(), 124 | ]; 125 | pathCommands.push({ 126 | type: PathCommandType.bezier, 127 | points: points, 128 | isDeadly, 129 | }); 130 | const interpolatedPoints = generateBezierSegments( 131 | [oldPos].concat(points), 132 | 0.1, 133 | ); 134 | for (const [p1, p2] of interpolatedPoints) { 135 | this.addStatic(p1, p2, isDeadly, GRASS_MASK | TREE_GROUND_MASK); 136 | } 137 | // #if process.env.NODE_ENV === 'development' 138 | for (const p of points) { 139 | pointsMap.set(p, pathCommands[pathCommands.length - 1]); 140 | } 141 | // #endif 142 | break; 143 | case "p": 144 | this.index_++; 145 | pos = this.parseVector(); 146 | const sizes = new Map([ 147 | ["P", [15, 5]], 148 | ["h", [40, 10]], 149 | ["H", [80, 10]], 150 | ["v", [10, 40]], 151 | ["V", [10, 80]], 152 | ["b", [40, 40]], 153 | ["M", [60, 60]], 154 | ]); 155 | const [w, h] = sizes.get(c)!; 156 | platforms.push({ 157 | x: pos.x - w, 158 | y: pos.y - h, 159 | w: w * 2, 160 | h: h * 2, 161 | isDeadly, 162 | }); 163 | this.generatePlatform(pos, w, h, isDeadly); 164 | // #if process.env.NODE_ENV === 'development' 165 | const types: { [key: string]: string } = { 166 | P: "platform", 167 | h: "platformH1", 168 | H: "platformH2", 169 | v: "platformV1", 170 | V: "platformV2", 171 | b: "platformB1", 172 | M: "platformB2", 173 | }; 174 | const platformObject: LevelObject = { 175 | type: types[c] as ObjectType, 176 | pos, 177 | isDeadly, 178 | }; 179 | objects.push(platformObject); 180 | pointsMap.set(pos, platformObject as any); 181 | // #endif 182 | break; 183 | case "s": 184 | const savepoint = this.parseNumber(); 185 | savepoints.push(savepoint); 186 | // #if process.env.NODE_ENV === 'development' 187 | const savepointPos = new Vector2( 188 | savepoint, 189 | this.engine.level_.size_.y / 2, 190 | ); 191 | const save: LevelObject = { 192 | type: "savepoint", 193 | pos: savepointPos, 194 | isDeadly: false, 195 | }; 196 | objects.push(save); 197 | pointsMap.set(savepointPos, save as any); 198 | // #endif 199 | break; 200 | case "C": 201 | pos = this.parseVector(); 202 | const collectedCrystals = 203 | this.engine.currentSave.crystals[this.engine.currentSave.level_] || 204 | []; 205 | pickables.push({ 206 | pos, 207 | type: PickableType.crystal, 208 | collected: collectedCrystals.includes(pickables.length), 209 | radius: 20, 210 | }); 211 | // #if process.env.NODE_ENV === 'development' 212 | const crystalObject: LevelObject = { 213 | isDeadly: false, 214 | type: "crystal", 215 | pos, 216 | }; 217 | objects.push(crystalObject); 218 | pointsMap.set(pos, crystalObject as any); 219 | // #endif 220 | break; 221 | case "G": 222 | pos = this.parseVector(); 223 | pickables.push({ 224 | pos, 225 | type: PickableType.gravityCrystal, 226 | collected: false, 227 | radius: 25, 228 | }); 229 | // #if process.env.NODE_ENV === 'development' 230 | const gravityCrystalObject: LevelObject = { 231 | isDeadly: false, 232 | type: "gravityCrystal", 233 | pos, 234 | }; 235 | objects.push(gravityCrystalObject); 236 | pointsMap.set(pos, gravityCrystalObject as any); 237 | // #endif 238 | break; 239 | case "B": 240 | pos = this.parseVector(); 241 | pickables.push({ 242 | pos, 243 | type: PickableType.bubble, 244 | collected: false, 245 | radius: 25, 246 | }); 247 | // #if process.env.NODE_ENV === 'development' 248 | const bubbleObject: LevelObject = { 249 | isDeadly: false, 250 | type: "bubble", 251 | pos, 252 | }; 253 | objects.push(bubbleObject); 254 | pointsMap.set(pos, bubbleObject as any); 255 | // #endif 256 | break; 257 | } 258 | c = this.d[this.index_ - 1]; 259 | } 260 | } 261 | 262 | private generatePlatform( 263 | pos: Vector2, 264 | w: number, 265 | h: number, 266 | isDeadly: boolean, 267 | ) { 268 | const [a, b, c, d] = [ 269 | pos.copy().add_(new Vector2(-w, -h)), 270 | pos.copy().add_(new Vector2(w, -h)), 271 | pos.copy().add_(new Vector2(w, h)), 272 | pos.copy().add_(new Vector2(-w, h)), 273 | ]; 274 | 275 | const mask = w > 30 ? GRASS_MASK : 0; 276 | this.addStatic(a, b, isDeadly, mask); 277 | this.addStatic(b, c, isDeadly, mask); 278 | this.addStatic(c, d, isDeadly, mask); 279 | this.addStatic(d, a, isDeadly, mask); 280 | } 281 | 282 | private parseVector() { 283 | return new Vector2(this.parseNumber(), this.parseNumber()); 284 | } 285 | 286 | private parseNumber() { 287 | let number = this.d[this.index_ - 1]; 288 | let c = this.next(); 289 | while (c >= "0" && c <= "9") { 290 | number += c; 291 | c = this.next(); 292 | } 293 | return parseFloat(number) * 10; 294 | } 295 | 296 | private next() { 297 | return this.d[this.index_++]; 298 | } 299 | 300 | private addStatic( 301 | start_: Vector2, 302 | end_: Vector2, 303 | isDeadly: boolean, 304 | mask = 0, 305 | ) { 306 | if (isDeadly) { 307 | mask = 0; 308 | } 309 | this.engine.physics.addStatic({ 310 | start_, 311 | end_, 312 | receiveMask: GROUND_MASK | mask, 313 | isDeadly, 314 | }); 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /src/menu.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "./game"; 2 | import { Save } from "./saves"; 3 | 4 | export const enum MenuMode { 5 | menu, 6 | stats, 7 | credits, 8 | } 9 | 10 | export class Menu { 11 | tintEl = document.getElementsByTagName("t")[0]; 12 | loadingEl = document.getElementsByTagName("w")[0]; 13 | optionsEl = document.getElementById("o")!; 14 | 15 | continueEl = document.getElementById("c")!; 16 | newGameEl = document.getElementById("n")!; 17 | 18 | finishScreenEl = document.getElementById("f")!; 19 | timeEl = document.getElementById("tm")!; 20 | crystalsEl = document.getElementById("p")!; 21 | deathsEl = document.getElementById("d")!; 22 | 23 | creditsEl = document.getElementById("r")!; 24 | 25 | mode: MenuMode = MenuMode.menu; 26 | 27 | constructor(game: Game) { 28 | this.continueEl.addEventListener("click", () => game.togglePause()); 29 | this.newGameEl.addEventListener("click", () => game.startNewGame()); 30 | this.optionsEl.classList.remove("r"); 31 | this.loadingEl.classList.add("r"); 32 | } 33 | 34 | private showTint() { 35 | this.tintEl.classList.remove("r"); 36 | this.optionsEl.classList.add("r"); 37 | this.finishScreenEl.classList.add("r"); 38 | this.creditsEl.classList.add("r"); 39 | } 40 | 41 | show() { 42 | this.showTint(); 43 | this.optionsEl.classList.remove("r"); 44 | } 45 | 46 | hide() { 47 | this.tintEl.classList.add("r"); 48 | } 49 | 50 | finish(save: Save) { 51 | this.showTint(); 52 | this.mode = MenuMode.stats; 53 | this.finishScreenEl.classList.remove("r"); 54 | 55 | let seconds = parseInt(localStorage.getItem("tww_t")!) / 1000; 56 | let minutes = Math.floor(seconds / 60); 57 | this.timeEl.innerText = `${minutes}m ${(seconds - minutes * 60).toFixed( 58 | 1, 59 | )}s`; 60 | 61 | this.crystalsEl.innerText = Object.values(save.crystals) 62 | .reduce((acc, c) => acc + c.length, 0) 63 | .toString(); 64 | 65 | this.deathsEl.innerText = localStorage.getItem("tww_d") || "0"; 66 | } 67 | 68 | showCredits() { 69 | this.mode = MenuMode.credits; 70 | this.finishScreenEl.classList.add("r"); 71 | this.creditsEl.classList.remove("r"); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/music.ts: -------------------------------------------------------------------------------- 1 | import "./ZzFX.micro"; 2 | 3 | export declare const zzfx: any; 4 | 5 | let lastTime = 0; 6 | 7 | // wind actually 8 | export function playMusic(time: number) { 9 | if (time - lastTime > 100) { 10 | lastTime = time; 11 | const freq = 12 | 170 + 13 | Math.sin(time / 631) * 40 + 14 | Math.cos(time / 487) * 60 + 15 | Math.sin(time / 227) * 30; 16 | zzfx(0.015, 2, freq, 1.5, 0.5, 0, 5, 0.1, 0); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/particles.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | import { Engine } from "./engine"; 3 | 4 | interface Particle { 5 | bornAt: number; 6 | lifetime: number; 7 | pos: Vector2; 8 | vel: Vector2; 9 | } 10 | 11 | interface EmitOptions { 12 | pos: Vector2; 13 | direction_: Vector2; 14 | count: number; 15 | lifetime: number; 16 | } 17 | 18 | export class ParticlesSystem { 19 | particles: Particle[] = []; 20 | 21 | constructor(private engine: Engine) {} 22 | 23 | update_() { 24 | for (const particle of this.particles) { 25 | particle.pos.add_(particle.vel); 26 | particle.vel.y += 0.03; 27 | if (this.engine.time_ > particle.bornAt + particle.lifetime) { 28 | const index = this.particles.indexOf(particle); 29 | this.particles.splice(index, 1); 30 | } 31 | } 32 | } 33 | 34 | emit(emitOptions: EmitOptions) { 35 | for (let i = 0; i < emitOptions.count; i++) { 36 | const vel = emitOptions.direction_ 37 | .copy() 38 | .rotate_((Math.random() - 0.5) * Math.PI * 2) 39 | .mul((Math.random() + 0.5) * 0.3); 40 | 41 | const particle: Particle = { 42 | bornAt: this.engine.time_, 43 | pos: emitOptions.pos.copy(), 44 | vel, 45 | lifetime: emitOptions.lifetime * (Math.random() + 0.5) * 5, 46 | }; 47 | 48 | this.particles.push(particle); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/physics/constants.ts: -------------------------------------------------------------------------------- 1 | export const GRID_SIZE = 100; 2 | -------------------------------------------------------------------------------- /src/physics/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getIndexOfCell(x: number, y: number) { 2 | return x * 10000 + y; 3 | } 4 | -------------------------------------------------------------------------------- /src/physics/physics.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "../vector"; 2 | import { 3 | getCircleCells, 4 | checkCircleLineColision, 5 | getLineCells, 6 | lineToLineColision, 7 | } from "./shapes"; 8 | 9 | export interface StaticBody { 10 | start_: Vector2; 11 | end_: Vector2; 12 | isDeadly: boolean; 13 | receiveMask: number; 14 | } 15 | 16 | export interface DynamicBody { 17 | radius: number; 18 | oldPos: Vector2; 19 | contactPoints: Vector2[]; 20 | pos: Vector2; 21 | vel: Vector2; 22 | } 23 | 24 | export interface StaticBodyColision { 25 | receiver_: StaticBody; 26 | point: Vector2; 27 | penetration: Vector2; 28 | } 29 | 30 | export class PhysicsSystem { 31 | grid: Map = new Map(); 32 | 33 | staticBodies: StaticBody[] = []; 34 | 35 | addStatic(body: StaticBody) { 36 | this.staticBodies.push(body); 37 | for (const cell of getLineCells(body.start_, body.end_)) { 38 | if (!this.grid.has(cell)) { 39 | this.grid.set(cell, [body]); 40 | } else { 41 | this.grid.get(cell)!.push(body); 42 | } 43 | } 44 | return body; 45 | } 46 | 47 | clear_() { 48 | this.staticBodies = []; 49 | this.grid.clear(); 50 | } 51 | 52 | *checkHitterColisions( 53 | hitter: DynamicBody, 54 | ): IterableIterator { 55 | hitter.contactPoints = []; 56 | const checked = new Set(); 57 | for (const cell of getCircleCells(hitter.pos, hitter.radius)) { 58 | for (const receiver of this.grid.get(cell) || []) { 59 | if (!checked.has(receiver)) { 60 | checked.add(receiver); 61 | const result = checkCircleLineColision( 62 | hitter.pos, 63 | hitter.radius, 64 | receiver.start_, 65 | receiver.end_, 66 | ); 67 | if (result) { 68 | yield { 69 | receiver_: receiver, 70 | penetration: result[0], 71 | point: result[1], 72 | }; 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | /** Casting rays supports only casting ray from top to bottom */ 80 | castRay(start_: Vector2, end_: Vector2): Vector2 | null { 81 | const cells = getLineCells(start_, end_); 82 | 83 | const intersections: Vector2[] = []; 84 | for (const cell of cells) { 85 | if (this.grid.has(cell)) { 86 | for (const body of this.grid.get(cell)!) { 87 | const intersection = lineToLineColision( 88 | start_, 89 | end_, 90 | body.start_, 91 | body.end_, 92 | ); 93 | if (intersection) { 94 | intersections.push(intersection); 95 | } 96 | } 97 | } 98 | } 99 | 100 | if (intersections.length) { 101 | return intersections.sort((p1, p2) => p1.y - p2.y)[0]; 102 | } 103 | 104 | return null; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/physics/player-physics.ts: -------------------------------------------------------------------------------- 1 | import { PhysicsSystem } from "./physics"; 2 | import { Player } from "../player"; 3 | import { Vector2 } from "../vector"; 4 | import { Pickable } from "../level.interface"; 5 | 6 | import "../ZzFX.micro"; 7 | 8 | export declare const zzfx: any; 9 | 10 | export const enum MotionMode { 11 | running, 12 | falling, 13 | climbing, 14 | bubbling, 15 | } 16 | 17 | export class PlayerPhysics { 18 | direction_: "l" | "r" = "r"; 19 | 20 | maxSpeed = 3.5; 21 | 22 | climbingThreshold = 4; 23 | 24 | lastJumpTime = 0; 25 | 26 | fallingTime = 0; 27 | 28 | dashed = false; 29 | 30 | climbContact: number | null; 31 | 32 | mode_: MotionMode = MotionMode.running; 33 | 34 | bubble: Pickable | null; 35 | 36 | bubbleTime = 0; 37 | 38 | lastBubbleFlySoundTime = 0; 39 | 40 | gravity = 0.25; 41 | 42 | antigravityTime = 0; 43 | 44 | hitTime = 0; 45 | 46 | constructor(private physics: PhysicsSystem, private player: Player) {} 47 | 48 | update_() { 49 | if (this.player.isDead) { 50 | return; 51 | } 52 | 53 | const body_ = this.player.body_; 54 | 55 | this.updateMode(); 56 | 57 | if ( 58 | this.antigravityTime && 59 | this.player.engine.time_ - this.antigravityTime > 3500 60 | ) { 61 | this.gravity = 0.25; 62 | this.player.body_.pos.y += 20; 63 | this.antigravityTime = 0; 64 | } 65 | 66 | body_.oldPos = body_.pos.copy(); 67 | if (this.mode_ === MotionMode.running) { 68 | body_.pos.x += body_.vel.x; 69 | const rayResult = this.physics.castRay( 70 | new Vector2( 71 | body_.pos.x, 72 | body_.pos.y - 20 * (this.gravity > 0 ? 1 : -1), 73 | ), 74 | new Vector2( 75 | body_.pos.x, 76 | body_.pos.y + 20 * (this.gravity > 0 ? 1 : -1), 77 | ), 78 | ); 79 | if (rayResult) { 80 | body_.pos.y = rayResult.y - body_.radius * (this.gravity > 0 ? 1 : -1); 81 | } 82 | if ( 83 | !this.player.isRunning || 84 | (this.player.body_.vel.x > 0 ? "r" : "l") !== this.direction_ 85 | ) { 86 | body_.vel.x *= 0.5; 87 | } 88 | body_.vel.y = 0; 89 | } 90 | 91 | if (this.mode_ === MotionMode.falling) { 92 | body_.vel.y += this.gravity; 93 | body_.vel.x *= 0.94; 94 | body_.pos.add_(body_.vel); 95 | 96 | this.player.targetScale = 1 + Math.abs(body_.vel.y / 17); 97 | } 98 | 99 | if (this.mode_ === MotionMode.climbing) { 100 | body_.vel.zero(); 101 | } 102 | 103 | if (this.mode_ === MotionMode.bubbling) { 104 | body_.pos.add_(body_.vel); 105 | 106 | zzfx( 107 | 0.06, 108 | 0.1, 109 | Math.sin(body_.vel.y / 5) * 200 + 100, 110 | 0.15, 111 | 0.51, 112 | 5, 113 | 2, 114 | 0, 115 | 0.09, 116 | ); 117 | 118 | this.player.engine.particles.emit({ 119 | count: 3, 120 | direction_: new Vector2(3, 0), 121 | lifetime: 120, 122 | pos: body_.pos, 123 | }); 124 | } 125 | 126 | /* 127 | Limit the speed to the diameter of circle. 128 | This way we avoid tunelling through terrain in high speeds. 129 | **/ 130 | const speed = Math.min(body_.vel.length_(), body_.radius); 131 | body_.vel = body_.vel.normalize_().mul(speed); 132 | 133 | if (this.mode_ !== MotionMode.climbing) { 134 | const colisions = Array.from(this.physics.checkHitterColisions(body_)); 135 | 136 | if (colisions.length) { 137 | if ( 138 | this.mode_ === MotionMode.falling && 139 | this.player.engine.time_ - this.fallingTime > 150 140 | ) { 141 | if (this.player.engine.time_ - this.hitTime > 150) { 142 | zzfx(1, 0.1, 304, 0.2, 0.01, 0, 0.3, 0, 0.5); 143 | } 144 | this.hitTime = this.player.engine.time_; 145 | 146 | this.player.targetScale = Math.min( 147 | 1, 148 | 1 / Math.abs(body_.vel.y) / 8 + 0.7, 149 | ); 150 | } 151 | if (this.mode_ === MotionMode.bubbling) { 152 | this.leaveBubbling(); 153 | } 154 | } 155 | 156 | for (const colision of colisions) { 157 | body_.contactPoints.push(colision.point); 158 | 159 | if (colision.receiver_.isDeadly) { 160 | this.player.die(); 161 | } 162 | 163 | const dy = colision.point.y - body_.pos.y; 164 | if ( 165 | this.gravity > 0 166 | ? dy <= this.climbingThreshold 167 | : dy >= -this.climbingThreshold 168 | ) { 169 | body_.pos.sub_(colision.penetration); 170 | } 171 | 172 | if (this.mode_ === MotionMode.falling) { 173 | const d = body_.pos.copy().sub_(body_.oldPos); 174 | const v = body_.vel; 175 | 176 | body_.vel.x = Math.abs(v.x) < Math.abs(d.x) ? v.x : d.x; 177 | body_.vel.y = Math.abs(v.y) < Math.abs(d.y) ? v.y : d.y; 178 | } 179 | } 180 | } 181 | body_.pos.x = Math.max(0, body_.pos.x); 182 | 183 | if (body_.pos.y > this.player.engine.level_.size_.y) { 184 | this.player.die(); 185 | } 186 | } 187 | 188 | private updateMode() { 189 | this.climbContact = null; 190 | 191 | if (this.mode_ === MotionMode.bubbling) { 192 | if (this.player.engine.time_ - this.bubbleTime > 1700) { 193 | this.leaveBubbling(); 194 | } else { 195 | return; 196 | } 197 | } 198 | 199 | for (const point of this.player.body_.contactPoints) { 200 | const dy = 201 | (point.y - this.player.body_.pos.y) * (this.gravity > 0 ? 1 : -1); 202 | const dx = point.x - this.player.body_.pos.x; 203 | if (dy > this.climbingThreshold) { 204 | this.mode_ = MotionMode.running; 205 | return; 206 | } else if (Math.abs(dy) <= this.climbingThreshold) { 207 | this.climbContact = dx; 208 | } 209 | } 210 | 211 | if (this.climbContact) { 212 | const keys_ = this.player.engine.control_.keys_; 213 | if ( 214 | (this.climbContact < 0 && keys_.get("ArrowLeft")) || 215 | (this.climbContact > 0 && keys_.get("ArrowRight")) 216 | ) { 217 | if (this.mode_ !== MotionMode.climbing) { 218 | this.lastJumpTime = this.player.engine.time_; 219 | } 220 | this.mode_ = MotionMode.climbing; 221 | this.dashed = false; 222 | return; 223 | } 224 | } 225 | 226 | if (this.mode_ !== MotionMode.falling) { 227 | this.fallingTime = this.player.engine.time_; 228 | this.mode_ = MotionMode.falling; 229 | this.dashed = false; 230 | } 231 | } 232 | 233 | moveToDirection(direction: number) { 234 | if (this.mode_ === MotionMode.bubbling) { 235 | this.player.body_.vel.rotate_(direction / 13); 236 | return; 237 | } 238 | 239 | this.player.isRunning = this.mode_ === MotionMode.running; 240 | let accScalar = 0.3; 241 | if (this.mode_ === MotionMode.running) { 242 | accScalar = 0.2; 243 | } 244 | 245 | const acc = direction * accScalar; 246 | 247 | if (Math.abs(this.player.body_.vel.x + acc) < this.maxSpeed) { 248 | this.player.body_.vel.x += acc; 249 | } 250 | 251 | this.direction_ = direction < 0 ? "l" : "r"; 252 | this.player.makeStep(); 253 | } 254 | 255 | jump() { 256 | if ( 257 | this.mode_ === MotionMode.running || 258 | this.mode_ === MotionMode.climbing || 259 | // be more forgiving to players by allowing them to jump after slipping 260 | // on platforms/slopes 261 | (this.mode_ === MotionMode.falling && 262 | this.player.engine.time_ - this.fallingTime < 150) 263 | ) { 264 | if (this.player.engine.time_ - this.lastJumpTime > 151) { 265 | this.player.body_.vel.y = 5; 266 | if (this.mode_ === MotionMode.climbing) { 267 | this.player.body_.vel.x = -this.climbContact! / 3; 268 | this.player.body_.vel.y = 5; 269 | } 270 | this.player.body_.vel.y *= this.gravity > 0 ? -1 : 1; 271 | this.player.body_.contactPoints = []; 272 | this.lastJumpTime = this.player.engine.time_; 273 | this.dashed = false; 274 | zzfx(0.6, 1, 150, 0.15, 0.47, 4.2, 1.4, 1, 0.25); 275 | return; 276 | } 277 | } 278 | 279 | if ( 280 | this.mode_ === MotionMode.falling && 281 | !this.dashed && 282 | this.player.engine.time_ - this.lastJumpTime > 300 283 | ) { 284 | this.player.body_.vel.y = 5 * (this.gravity > 0 ? -1 : 1); 285 | this.dashed = true; 286 | zzfx(0.6, 1, 200, 0.1, 0.47, 4.2, 1.4, 1, 0.15); 287 | } 288 | 289 | if ( 290 | this.mode_ === MotionMode.bubbling && 291 | this.player.engine.time_ - this.lastJumpTime > 150 292 | ) { 293 | this.leaveBubbling(); 294 | } 295 | } 296 | 297 | enterBubble(bubble: Pickable) { 298 | if (this.mode_ === MotionMode.bubbling) { 299 | this.leaveBubbling(); 300 | } else { 301 | this.player.body_.vel = new Vector2(0, -5); 302 | this.player.body_.pos.x = bubble.pos.x; 303 | this.player.body_.pos.y = bubble.pos.y; 304 | } 305 | this.mode_ = MotionMode.bubbling; 306 | this.bubble = bubble; 307 | this.bubbleTime = this.player.engine.time_; 308 | this.lastJumpTime = this.player.engine.time_; 309 | } 310 | 311 | leaveBubbling() { 312 | this.mode_ = MotionMode.falling; 313 | this.dashed = false; 314 | this.lastJumpTime = this.player.engine.time_; 315 | this.player.engine.particles.emit({ 316 | count: 250, 317 | direction_: new Vector2(8, 0), 318 | lifetime: 80, 319 | pos: this.player.body_.pos 320 | .copy() 321 | .add_(this.player.body_.vel.copy().mul(9)), 322 | }); 323 | const bubble = this.bubble!; 324 | this.bubble = null; 325 | zzfx(1, 0.1, 428, 0.2, 0.31, 0, 0.2, 5.1, 0.42); 326 | setTimeout(() => (bubble.collected = false), 1000); 327 | } 328 | 329 | enterAntigravity() { 330 | this.gravity = -0.25; 331 | this.dashed = false; 332 | this.antigravityTime = this.player.engine.time_; 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/physics/shapes.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "../vector"; 2 | import { GRID_SIZE } from "./constants"; 3 | import { getIndexOfCell } from "./helpers"; 4 | 5 | export function lineToPointColision( 6 | lineStart: Vector2, 7 | lineEnd: Vector2, 8 | point: Vector2, 9 | ) { 10 | const d1 = lineStart.distanceTo(point); 11 | const d2 = lineEnd.distanceTo(point); 12 | 13 | const lineLen = lineStart.distanceTo(lineEnd); 14 | 15 | const buffer = 0.1; 16 | 17 | return d1 + d2 >= lineLen - buffer && d1 + d2 <= lineLen + buffer; 18 | } 19 | 20 | export function* getCircleCells(pos: Vector2, r: number) { 21 | const minX = Math.floor((pos.x - r) / GRID_SIZE); 22 | const maxX = Math.ceil((pos.x + r) / GRID_SIZE); 23 | 24 | const minY = Math.floor((pos.y - r) / GRID_SIZE); 25 | const maxY = Math.ceil((pos.y + r) / GRID_SIZE); 26 | 27 | for (let x = minX; x <= maxX; x++) { 28 | for (let y = minY; y <= maxY; y++) { 29 | yield getIndexOfCell(x, y); 30 | } 31 | } 32 | } 33 | 34 | export function lineToLineColision( 35 | a1: Vector2, 36 | a2: Vector2, 37 | b1: Vector2, 38 | b2: Vector2, 39 | ) { 40 | // calculate the distance to intersection point 41 | const uA = 42 | ((b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x)) / 43 | ((b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y)); 44 | const uB = 45 | ((a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x)) / 46 | ((b2.y - b1.y) * (a2.x - a1.x) - (b2.x - b1.x) * (a2.y - a1.y)); 47 | 48 | // if uA and uB are between 0-1, lines are colliding 49 | if (uA >= 0 && uA <= 1 && uB >= 0 && uB <= 1) { 50 | return new Vector2(a1.x + uA * (a2.x - a1.x), a1.y + uA * (a2.y - a1.y)); 51 | } 52 | return null; 53 | } 54 | 55 | export function* getLineCells(start_: Vector2, end_: Vector2) { 56 | const minX = Math.floor(Math.min(start_.x, end_.x) / GRID_SIZE); 57 | const maxX = Math.ceil(Math.max(start_.x, end_.x) / GRID_SIZE); 58 | 59 | const minY = Math.floor(Math.min(start_.y, end_.y) / GRID_SIZE); 60 | const maxY = Math.ceil(Math.max(start_.y, end_.y) / GRID_SIZE); 61 | 62 | for (let x = minX; x <= maxX; x++) { 63 | for (let y = minY; y <= maxY; y++) { 64 | yield getIndexOfCell(x, y); 65 | } 66 | } 67 | } 68 | 69 | export function checkCircleLineColision( 70 | cPos: Vector2, 71 | r: number, 72 | lineStart: Vector2, 73 | lineEnd: Vector2, 74 | ): [Vector2, Vector2] | null { 75 | const length = lineStart.distanceTo(lineEnd); 76 | const dot = 77 | ((cPos.x - lineStart.x) * (lineEnd.x - lineStart.x) + 78 | (cPos.y - lineStart.y) * (lineEnd.y - lineStart.y)) / 79 | Math.pow(length, 2); 80 | 81 | let closestPoint = new Vector2( 82 | lineStart.x + dot * (lineEnd.x - lineStart.x), 83 | lineStart.y + dot * (lineEnd.y - lineStart.y), 84 | ); 85 | 86 | const onSegment = lineToPointColision(lineStart, lineEnd, closestPoint); 87 | if (!onSegment) { 88 | if (cPos.distanceTo(lineStart) <= r) { 89 | closestPoint = lineStart; 90 | } else if (cPos.distanceTo(lineEnd) <= r) { 91 | closestPoint = lineEnd; 92 | } else { 93 | return null; 94 | } 95 | } 96 | 97 | const penetrationDistance = r - cPos.distanceTo(closestPoint); 98 | if (penetrationDistance < 0) { 99 | return null; 100 | } 101 | 102 | const penetrationVec = closestPoint 103 | .copy() 104 | .sub_(cPos) 105 | .normalize_() 106 | .mul(penetrationDistance); 107 | 108 | return [penetrationVec, closestPoint]; 109 | } 110 | -------------------------------------------------------------------------------- /src/plants.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | import { Random } from "./random"; 3 | import { SpriteRenderer } from "./renderer/sprite-renderer"; 4 | 5 | export interface PlantDefinition { 6 | frames_: HTMLImageElement[]; 7 | spread: number; 8 | mask_: number; 9 | } 10 | 11 | export function generateGrass( 12 | ctx: CanvasRenderingContext2D, 13 | size: number, 14 | seed: number, 15 | time: number, 16 | ) { 17 | const r = new Random(seed); 18 | 19 | const grd = ctx.createLinearGradient(0, 0, 0, 50); 20 | grd.addColorStop(0, "#111"); 21 | grd.addColorStop(1, "#000"); 22 | 23 | ctx.strokeStyle = grd; 24 | 25 | ctx.lineWidth = 0.2; 26 | for (let i = 0; i < 10; i++) { 27 | let pos = new Vector2((r.next_() % 10) + 20, 50); 28 | ctx.beginPath(); 29 | ctx.moveTo(pos.x, pos.y); 30 | let angle = (r.nextFloat() + 0.5) / 10; 31 | let totalAngle = 0; 32 | const length = (r.nextFloat() + 1) * size; 33 | for (let j = 0; j < 10; j++) { 34 | const d = new Vector2(0, -1).rotate_(totalAngle).mul(length); 35 | pos = pos.copy().add_(d); 36 | ctx.lineTo(pos.x, pos.y); 37 | totalAngle += angle + Math.sin(time) / 30; 38 | } 39 | ctx.stroke(); 40 | ctx.closePath(); 41 | } 42 | } 43 | 44 | export function generateTree( 45 | ctx: CanvasRenderingContext2D, 46 | size: number, 47 | depth: number, 48 | angle: number, 49 | segmentLength: number, 50 | seed: number, 51 | time: number, 52 | ) { 53 | const r = new Random(seed); 54 | 55 | const grd = ctx.createLinearGradient(0, 0, 0, size); 56 | grd.addColorStop(0.5, "#151515"); 57 | grd.addColorStop(1, "#000"); 58 | 59 | ctx.strokeStyle = grd; 60 | drawTree( 61 | r, 62 | ctx, 63 | new Vector2(size / 2, size), 64 | depth, 65 | angle, 66 | 0, 67 | segmentLength, 68 | time, 69 | 1 / Math.pow(depth, 1.5), 70 | ); 71 | } 72 | 73 | function drawTree( 74 | r: Random, 75 | ctx: CanvasRenderingContext2D, 76 | pos: Vector2, 77 | depth: number, 78 | angle: number, 79 | totalAngle: number, 80 | segmentLength: number, 81 | time: number, 82 | animationPower: number, 83 | ) { 84 | const windDirection = Math.PI / 2; 85 | const angleDiff = windDirection - totalAngle; 86 | totalAngle += 87 | ((1 + angleDiff * 1.2 * (Math.sin(time) + 2)) / depth) * animationPower; 88 | 89 | const d = new Vector2(0, -1).rotate_(totalAngle).mul(segmentLength); 90 | const newPos = pos.copy().add_(d); 91 | 92 | ctx.beginPath(); 93 | ctx.lineWidth = Math.pow(depth, 0.9); 94 | ctx.moveTo(pos.x, pos.y); 95 | ctx.lineTo(newPos.x, newPos.y); 96 | ctx.stroke(); 97 | ctx.closePath(); 98 | 99 | if (depth >= 1) { 100 | drawTree( 101 | r, 102 | ctx, 103 | newPos, 104 | depth - 1, 105 | angle * (1 + (r.nextFloat() - 0.5) / 3), 106 | totalAngle + angle, 107 | segmentLength * Math.min(r.nextFloat() + 0.4, 0.8), 108 | time, 109 | animationPower, 110 | ); 111 | drawTree( 112 | r, 113 | ctx, 114 | newPos, 115 | depth - 1, 116 | angle * (1 + (r.nextFloat() - 0.5) / 3), 117 | totalAngle - angle, 118 | segmentLength * Math.min(r.nextFloat() + 0.4, 0.8), 119 | time, 120 | animationPower, 121 | ); 122 | } 123 | } 124 | 125 | export async function animateTree( 126 | spritesRenderer: SpriteRenderer, 127 | depth: number, 128 | angle: number, 129 | segmentLength: number, 130 | seed: number, 131 | ): Promise { 132 | const size = (depth * segmentLength) / 1.5; 133 | spritesRenderer.setSize(size, size); 134 | 135 | return renderFrames(spritesRenderer, 30, (ctx, time) => 136 | generateTree(ctx, size, depth, angle, segmentLength, seed, time), 137 | ); 138 | } 139 | 140 | export function animateGrass( 141 | spritesRenderer: SpriteRenderer, 142 | size: number, 143 | seed: number, 144 | ): Promise { 145 | spritesRenderer.setSize(50, 50); 146 | 147 | return renderFrames(spritesRenderer, 15, (ctx, time) => 148 | generateGrass(ctx, size, seed, time), 149 | ); 150 | } 151 | 152 | async function renderFrames( 153 | spritesRenderer: SpriteRenderer, 154 | framesCount: number, 155 | renderFn: (ctx: CanvasRenderingContext2D, time: number) => void, 156 | ) { 157 | const frames: HTMLImageElement[] = []; 158 | const step = Math.PI / framesCount; 159 | let time = Math.PI / 2; 160 | for (let i = 0; i < framesCount; i++) { 161 | time = time += step; 162 | frames.push(await spritesRenderer.render(ctx => renderFn(ctx, time))); 163 | } 164 | return frames; 165 | } 166 | -------------------------------------------------------------------------------- /src/player.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "./engine"; 2 | import { Vector2 } from "./vector"; 3 | import { assets } from "./assets"; 4 | import { PlayerPhysics, MotionMode } from "./physics/player-physics"; 5 | import { PickableType } from "./level.interface"; 6 | import { loadSave } from "./saves"; 7 | import { DynamicBody } from "./physics/physics"; 8 | 9 | import "./ZzFX.micro"; 10 | import { lerp } from "./utils"; 11 | 12 | export declare const zzfx: any; 13 | 14 | interface AgentAnimation { 15 | headOffset: number; 16 | lArmRot: number; 17 | rArmRot: number; 18 | lLegRot: number; 19 | rLegRot: number; 20 | eyesScale: number; 21 | eyesOffset: number; 22 | scale_: number; 23 | blinkTime: number; 24 | } 25 | 26 | export class Player { 27 | STEPS_RATE = 90; 28 | 29 | lastStepTime = 0; 30 | 31 | lastEyeLook = 0; 32 | 33 | body_: DynamicBody; 34 | 35 | stretch = new Vector2(1, 1); 36 | 37 | isDead = true; 38 | 39 | animation_: AgentAnimation = { 40 | headOffset: 0, 41 | lArmRot: -1, 42 | rArmRot: 1, 43 | lLegRot: 0, 44 | rLegRot: 0, 45 | eyesScale: 1, 46 | eyesOffset: -15, 47 | scale_: 1, 48 | blinkTime: 0, 49 | }; 50 | 51 | physics: PlayerPhysics; 52 | 53 | isRunning = false; 54 | 55 | targetScale = 1; 56 | 57 | constructor(public engine: Engine, pos: Vector2) { 58 | this.createBody(pos); 59 | this.physics = new PlayerPhysics(engine.physics, this); 60 | } 61 | 62 | updateControls() { 63 | if (this.isDead || this.engine.game.stopped_) { 64 | return; 65 | } 66 | const control = this.engine.control_; 67 | if (control.keys_.get("Space")) { 68 | this.physics.jump(); 69 | } 70 | if (this.physics.mode_ !== MotionMode.climbing) { 71 | if (control.keys_.get("ArrowLeft")) { 72 | this.physics.moveToDirection(-1); 73 | } 74 | if (control.keys_.get("ArrowRight")) { 75 | this.physics.moveToDirection(1); 76 | } 77 | } 78 | } 79 | 80 | updateAnimation() { 81 | this.animation_.lLegRot = 0; 82 | this.animation_.rLegRot = 0; 83 | this.animation_.lArmRot = -1; 84 | this.animation_.rArmRot = 1; 85 | 86 | if (Math.abs(this.animation_.scale_ - this.targetScale) < 0.05) { 87 | this.targetScale = 1; 88 | } 89 | this.animation_.scale_ = lerp( 90 | this.animation_.scale_, 91 | this.targetScale, 92 | 0.25, 93 | ); 94 | 95 | if (this.isRunning) { 96 | this.animation_.lLegRot = Math.sin(this.engine.time_ / 30) / 2; 97 | this.animation_.rLegRot = Math.cos(this.engine.time_ / 30) / 2; 98 | } 99 | 100 | if (this.physics.mode_ === MotionMode.climbing) { 101 | this.animation_.lLegRot = -0.6; 102 | this.animation_.rLegRot = -0.7; 103 | this.animation_.lArmRot = -1.3; 104 | this.animation_.rArmRot = -0.7; 105 | } 106 | 107 | if (this.engine.time_ - this.lastEyeLook > 100) { 108 | this.lastEyeLook = this.engine.time_; 109 | if (this.body_.vel.y > 1) { 110 | this.animation_.eyesOffset = -11; 111 | } else if (this.body_.vel.y < -1) { 112 | this.animation_.eyesOffset = -21; 113 | } else { 114 | this.animation_.eyesOffset = -15; 115 | } 116 | } 117 | 118 | if (this.physics.mode_ === MotionMode.falling) { 119 | if (this.body_.vel.y > 0.3) { 120 | this.animation_.lArmRot = -1.5 + Math.sin(this.engine.time_ / 50) / 3; 121 | this.animation_.rArmRot = 1.5 + Math.cos(this.engine.time_ / 50) / 3; 122 | this.animation_.lLegRot = 0.3; 123 | this.animation_.rLegRot = -0.3; 124 | } else { 125 | this.animation_.lArmRot = 126 | -0.7 + Math.sin(this.engine.time_ / 200) / 10; 127 | this.animation_.rArmRot = 0.7 - Math.sin(this.engine.time_ / 200) / 10; 128 | } 129 | } 130 | 131 | this.animation_.headOffset = Math.sin(this.engine.time_ / 200) - 2; 132 | 133 | if (Math.random() > 0.99) { 134 | this.animation_.blinkTime = this.engine.time_; 135 | } 136 | 137 | const blink = this.engine.time_ - this.animation_.blinkTime; 138 | if (blink < 200) { 139 | const step = Math.PI / 100; 140 | 141 | const radians = blink * step; 142 | this.animation_.eyesScale = (Math.cos(radians) + 1) / 2; 143 | } else { 144 | this.animation_.eyesScale = 1; 145 | } 146 | } 147 | 148 | makeStep() { 149 | if (this.engine.time_ - this.lastStepTime > this.STEPS_RATE) { 150 | this.lastStepTime = this.engine.time_; 151 | if (this.body_.contactPoints.length > 0) { 152 | zzfx(0.4, 0.6, 50, 0.02, 0.54, 4, 0.9, 10.7, 0.37); 153 | } 154 | } 155 | } 156 | 157 | update_() { 158 | this.isRunning = false; 159 | this.updateControls(); 160 | this.physics.update_(); 161 | this.checkPickables(); 162 | this.updateAnimation(); 163 | } 164 | 165 | checkPickables() { 166 | const pickables = this.engine.level_.pickables; 167 | for (const [index, pickable] of pickables.entries()) { 168 | if ( 169 | !pickable.collected && 170 | pickable.pos.distanceTo(this.body_.pos) < pickable.radius 171 | ) { 172 | pickable.collected = true; 173 | switch (pickable.type) { 174 | case PickableType.crystal: 175 | const save = this.engine.currentSave; 176 | if (!save.crystals[save.level_]) { 177 | save.crystals[save.level_] = []; 178 | } 179 | save.crystals[save.level_].push(index); 180 | zzfx(0.8, 0, 10, 0.2, 0.88, 1, 0.3, 10, 0.41); 181 | break; 182 | case PickableType.gravityCrystal: 183 | this.physics.enterAntigravity(); 184 | setTimeout(() => (pickable.collected = false), 5000); 185 | zzfx(0.8, 0, 10, 0.2, 0.88, 1, 0.3, 10, 0.41); 186 | break; 187 | case PickableType.bubble: 188 | this.physics.enterBubble(pickable); 189 | break; 190 | } 191 | } 192 | } 193 | } 194 | 195 | die() { 196 | this.isDead = true; 197 | 198 | localStorage.setItem( 199 | "tww_d", 200 | ((parseInt(localStorage.getItem("tww_d")!) || 0) + 1).toString(), 201 | ); // increment deaths counter 202 | this.engine.saveGameTime(); 203 | 204 | this.engine.particles.emit({ 205 | count: 250, 206 | direction_: new Vector2(5, 0), 207 | lifetime: 150, 208 | pos: this.body_.pos, 209 | }); 210 | 211 | zzfx(0.8, 0.7, 450, 0.5, 0.21, 11.3, 0.8, 7, 0.56); 212 | 213 | setTimeout(() => { 214 | this.engine.load_(loadSave()); 215 | }, 1000); 216 | } 217 | 218 | createBody(pos: Vector2) { 219 | this.isDead = false; 220 | this.body_ = { 221 | radius: 10, 222 | pos: pos, 223 | oldPos: pos.copy(), 224 | vel: new Vector2(), 225 | contactPoints: [], 226 | }; 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Taken from https://gist.github.com/blixt/f17b47c62508be59987b 3 | * 4 | * Creates a pseudo-random value generator. The seed must be an integer. 5 | * 6 | * Uses an optimized version of the Park-Miller PRNG. 7 | * http://www.firstpr.com.au/dsp/rand31/ 8 | */ 9 | export class Random { 10 | private seed_: number; 11 | constructor(seed_: number) { 12 | this.seed_ = seed_ % 2147483647; 13 | if (this.seed_ <= 0) this.seed_ += 2147483646; 14 | } 15 | 16 | /** 17 | * Returns a pseudo-random value between 1 and 2^32 - 2. 18 | */ 19 | next_() { 20 | return (this.seed_ = (this.seed_ * 16807) % 2147483647); 21 | } 22 | 23 | /** 24 | * Returns a pseudo-random floating point number in range [0, 1). 25 | */ 26 | nextFloat() { 27 | // We know that result of next() will be 1 to 2147483646 (inclusive). 28 | return (this.next_() - 1) / 2147483646; 29 | } 30 | 31 | nextVariation() { 32 | return this.nextFloat() - 0.5; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/layer.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "../engine"; 2 | 3 | export interface LayerOptions { 4 | canvas_?: HTMLCanvasElement; 5 | renderWholeWorld?: boolean; 6 | clear_?: boolean; 7 | offset_?: boolean; 8 | offsetScale?: number; 9 | } 10 | 11 | export class Layer { 12 | static layers: Layer[] = []; 13 | 14 | renderWholeWorld = false; 15 | 16 | clear_ = false; 17 | 18 | offset_ = false; 19 | 20 | offsetScale = 1; 21 | 22 | canvas_!: HTMLCanvasElement; 23 | 24 | ctx!: CanvasRenderingContext2D; 25 | 26 | constructor(private engine: Engine, options: LayerOptions = {}) { 27 | Layer.layers.push(this); 28 | Object.assign(this, options); 29 | 30 | if (!this.canvas_) { 31 | this.canvas_ = document.createElement("canvas"); 32 | } 33 | 34 | this.ctx = this.canvas_.getContext("2d") as CanvasRenderingContext2D; 35 | } 36 | 37 | updateSize() { 38 | if (this.renderWholeWorld) { 39 | this.canvas_.width = this.engine.level_.size_.x; 40 | this.canvas_.height = 41 | this.offsetScale === 1 42 | ? this.engine.level_.size_.y 43 | : this.engine.canvas_.height; 44 | } else { 45 | this.canvas_.width = this.engine.canvas_.width; 46 | this.canvas_.height = this.engine.canvas_.height; 47 | } 48 | this.clearCanvas(); 49 | } 50 | 51 | clearCanvas() { 52 | this.ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height); 53 | } 54 | 55 | activate() { 56 | const renderer = this.engine.renderer; 57 | 58 | renderer.activeLayer = this; 59 | renderer.ctx = this.ctx; 60 | 61 | if (this.clear_) { 62 | this.clearCanvas(); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/renderer.ts: -------------------------------------------------------------------------------- 1 | import { Layer } from "./layer"; 2 | 3 | import { Engine } from "../engine"; 4 | import { Vector2 } from "../vector"; 5 | import { PathCommandType, PickableType, Pickable } from "../level.interface"; 6 | import { assets } from "../assets"; 7 | import { Random } from "../random"; 8 | import { MotionMode } from "../physics/player-physics"; 9 | import { LEVELS } from "../levels"; 10 | import { GRASS_MASK } from "../colisions-masks"; 11 | 12 | export class Renderer { 13 | ctx: CanvasRenderingContext2D; 14 | 15 | activeLayer: Layer; 16 | 17 | baseLayer = new Layer(this.engine, { 18 | canvas_: this.engine.canvas_, 19 | }); 20 | 21 | skyLayer = new Layer(this.engine, {}); 22 | 23 | terrainLayer = new Layer(this.engine, { 24 | renderWholeWorld: true, 25 | offset_: true, 26 | clear_: true, 27 | }); 28 | 29 | hillsLayers: Layer[]; 30 | 31 | constructor(public engine: Engine) { 32 | this.hillsLayers = [0.2, 0.35, 0.5].map( 33 | offsetScale => 34 | new Layer(this.engine, { 35 | renderWholeWorld: true, 36 | offset_: true, 37 | offsetScale, 38 | }), 39 | ); 40 | } 41 | 42 | init() { 43 | for (const layer of Layer.layers) { 44 | layer.clearCanvas(); 45 | } 46 | this.updateSize(); 47 | this.prerender(); 48 | } 49 | 50 | renderTerrain() { 51 | let to: Vector2; 52 | for (const pathCommand of this.engine.level_.pathCommands) { 53 | switch (pathCommand.type) { 54 | case PathCommandType.move: 55 | to = pathCommand.points![0]; 56 | this.ctx.beginPath(); 57 | this.ctx.moveTo(to.x, to.y); 58 | break; 59 | case PathCommandType.line: 60 | to = pathCommand.points![0]; 61 | this.ctx.lineTo(to.x, to.y); 62 | break; 63 | case PathCommandType.bezier: 64 | const [c1, c2, to_] = pathCommand.points!; 65 | this.ctx.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, to_.x, to_.y); 66 | break; 67 | case PathCommandType.close: 68 | this.ctx.closePath(); 69 | this.ctx.fill(); 70 | break; 71 | } 72 | } 73 | } 74 | 75 | renderPlatforms() { 76 | this.ctx.beginPath(); 77 | for (const p of this.engine.level_.platforms) { 78 | this.ctx.fillRect(p.x, p.y, p.w, p.h); 79 | } 80 | } 81 | 82 | renderSpikes() { 83 | const r = new Random(1); 84 | 85 | const deadlyBodies = this.engine.physics.staticBodies.filter( 86 | b => b.isDeadly, 87 | ); 88 | for (const body of deadlyBodies) { 89 | const angle = body.start_.directionTo(body.end_); 90 | const length = body.start_.distanceTo(body.end_); 91 | this.ctx.save(); 92 | this.ctx.translate(body.start_.x, body.start_.y); 93 | this.ctx.rotate(angle); 94 | let y = 0; 95 | while (y < length) { 96 | this.ctx.beginPath(); 97 | this.ctx.moveTo(-4, y); 98 | this.ctx.lineTo(-4, y + 4); 99 | this.ctx.lineTo(9 + 7 * r.nextFloat(), y + 2); 100 | this.ctx.closePath(); 101 | this.ctx.fill(); 102 | y += 5; 103 | } 104 | this.ctx.restore(); 105 | } 106 | } 107 | 108 | renderPlayer() { 109 | const ctx = this.ctx; 110 | 111 | const player = this.engine.player; 112 | 113 | ctx.save(); 114 | 115 | ctx.translate( 116 | player.body_.pos.x, 117 | player.body_.pos.y + 10 * (player.physics.gravity > 0 ? 1 : -1), 118 | ); 119 | 120 | if (player.physics.mode_ === MotionMode.bubbling) { 121 | ctx.translate(0, -10); 122 | ctx.rotate(player.body_.vel.angle_() + Math.PI); 123 | ctx.scale(0.9, 1.1); 124 | } else { 125 | ctx.scale( 126 | 1, 127 | player.animation_.scale_ * (player.physics.gravity > 0 ? 1 : -1), 128 | ); 129 | } 130 | 131 | if (player.physics.direction_ === "l") { 132 | ctx.scale(-1, 1); 133 | } 134 | 135 | // legs 136 | ctx.save(); 137 | ctx.translate(-3, -10); 138 | ctx.rotate(player.animation_.lLegRot); 139 | ctx.drawImage(assets.limb, 0, 0, 5, 10); 140 | ctx.restore(); 141 | 142 | ctx.save(); 143 | ctx.translate(1, -10); 144 | ctx.rotate(player.animation_.rLegRot); 145 | ctx.drawImage(assets.limb, 0, 0, 5, 10); 146 | ctx.restore(); 147 | 148 | ctx.drawImage(assets.torso, -20, -36, 40, 40); 149 | 150 | ctx.save(); 151 | ctx.translate(0, player.animation_.headOffset); 152 | 153 | // arms 154 | ctx.save(); 155 | ctx.translate(-3, -13); 156 | ctx.rotate(player.animation_.rArmRot); 157 | ctx.scale(0.8, 0.8); 158 | ctx.drawImage(assets.limb, 0, 0, 5, 10); 159 | ctx.restore(); 160 | 161 | ctx.save(); 162 | ctx.translate(3, -10); 163 | ctx.rotate(player.animation_.lArmRot); 164 | ctx.scale(0.8, 0.8); 165 | ctx.drawImage(assets.limb, 0, 0, 5, 10); 166 | ctx.restore(); 167 | 168 | //head 169 | ctx.drawImage(assets.head_, -20, -33); 170 | 171 | // eyes 172 | ctx.translate(0, player.animation_.eyesOffset); 173 | ctx.scale(1, player.animation_.eyesScale); 174 | ctx.drawImage(assets.eyes, -3, -8); 175 | ctx.restore(); 176 | 177 | if (player.physics.mode_ === MotionMode.bubbling) { 178 | ctx.rotate(-player.body_.vel.angle_() - Math.PI); 179 | this.renderBubble( 180 | new Vector2(0, -18).rotate_(player.body_.vel.angle_() + Math.PI), 181 | false, 182 | ); 183 | } 184 | 185 | ctx.restore(); 186 | } 187 | 188 | renderSky() { 189 | const canvas = this.engine.renderer.activeLayer.canvas_; 190 | const grd = this.ctx.createLinearGradient(0, 0, 0, canvas.height); 191 | grd.addColorStop(0, "#555"); 192 | grd.addColorStop(1, "#111"); 193 | this.ctx.fillStyle = grd; 194 | this.ctx.fillRect(0, 0, canvas.width, canvas.height); 195 | 196 | const gradient = this.ctx.createRadialGradient( 197 | 150, 198 | 100, 199 | 10, 200 | 150, 201 | 100, 202 | 300, 203 | ); 204 | gradient.addColorStop(0, "#ddd"); 205 | gradient.addColorStop(0.03, "#ddd"); 206 | gradient.addColorStop(0.04, "#777"); 207 | gradient.addColorStop(1, "transparent"); 208 | this.ctx.fillStyle = gradient; 209 | this.ctx.fillRect(0, 0, canvas.width, canvas.height); 210 | } 211 | 212 | renderHills( 213 | color: string, 214 | size: number, 215 | amplitude: number, 216 | spread: number, 217 | seed: number, 218 | ) { 219 | const r = new Random(seed); 220 | const canvas = this.engine.renderer.activeLayer.canvas_; 221 | 222 | const grd = this.ctx.createLinearGradient(0, 0, 0, canvas.height); 223 | grd.addColorStop(0, color); 224 | grd.addColorStop(1, "#111"); 225 | this.ctx.filter = `blur(${2 / 226 | this.engine.renderer.activeLayer.offsetScale}px)`; 227 | 228 | this.ctx.fillStyle = grd; 229 | this.ctx.lineWidth = 0; 230 | let x = 0; 231 | while (x < canvas.width) { 232 | this.ctx.beginPath(); 233 | this.ctx.save(); 234 | this.ctx.translate(x, canvas.height); 235 | this.ctx.scale(1, amplitude + r.nextVariation() * amplitude * 0.5); 236 | this.ctx.arc(0, 0, size, Math.PI, Math.PI * 2); 237 | this.ctx.closePath(); 238 | this.ctx.fill(); 239 | this.ctx.restore(); 240 | x += spread + r.nextVariation() * spread * 0.5; 241 | } 242 | } 243 | 244 | renderFoliage(isForeGround: boolean) { 245 | const minX = this.engine.camera.pos.x - 100; 246 | const maxX = this.engine.camera.pos.x + this.engine.canvas_.width + 100; 247 | 248 | for (let x = minX; x < maxX; x += this.engine.foliage.GRID_SIZE) { 249 | const cell = Math.floor(x / this.engine.foliage.GRID_SIZE); 250 | for (const foliage of this.engine.foliage.entities_[cell] || []) { 251 | if (foliage.isForeground !== isForeGround) { 252 | continue; 253 | } 254 | 255 | const framesCount = foliage.definition.frames_.length; 256 | let frame = Math.abs( 257 | (Math.round(this.engine.time_ / 40 + foliage.pos.x) % 258 | (framesCount * 2)) - 259 | framesCount, 260 | ); 261 | if (frame === framesCount) { 262 | frame = framesCount - 1; 263 | } 264 | const image = foliage.definition.frames_[frame]; 265 | this.ctx.drawImage( 266 | image, 267 | foliage.pos.x - image.width / 2, 268 | foliage.pos.y - image.height + 5, 269 | ); 270 | } 271 | } 272 | } 273 | 274 | renderLight(pos: Vector2, color: string, size: number) { 275 | this.ctx.save(); 276 | this.ctx.translate(pos.x, pos.y - 1); 277 | 278 | size = size + Math.sin(this.engine.time_ / 300) * 5; 279 | 280 | const grd = this.ctx.createRadialGradient(0, 0, 10, 0, 0, size); 281 | grd.addColorStop(0, color); 282 | grd.addColorStop(1, "transparent"); 283 | this.ctx.fillStyle = grd; 284 | this.ctx.fillRect(-size, -size, size * 2, size * 2); 285 | 286 | this.ctx.restore(); 287 | } 288 | 289 | renderParticles() { 290 | this.ctx.strokeStyle = "#fff"; 291 | this.ctx.lineWidth = 0.5; 292 | for (const particle of this.engine.particles.particles) { 293 | const pos = particle.pos; 294 | const vel = particle.vel; 295 | 296 | this.ctx.beginPath(); 297 | this.ctx.moveTo(pos.x, pos.y); 298 | this.ctx.lineTo(pos.x + vel.x, pos.y + vel.y); 299 | this.ctx.stroke(); 300 | } 301 | } 302 | 303 | renderRain() { 304 | const r = new Random(1); 305 | 306 | this.ctx.lineWidth = 0.5; 307 | this.ctx.strokeStyle = "#fff3"; 308 | for (let i = 0; i < 50; i++) { 309 | const velX = 0.6 + r.nextFloat() * 0.8; 310 | const velY = 0.2 + r.nextFloat() * 0.2; 311 | const posX = 312 | (r.nextFloat() * this.activeLayer.canvas_.width + 313 | (velX * this.engine.time_) / 5) % 314 | this.activeLayer.canvas_.width; 315 | const posY = 316 | (r.nextFloat() * this.activeLayer.canvas_.height + 317 | (velY * this.engine.time_) / 5) % 318 | this.activeLayer.canvas_.height; 319 | this.ctx.beginPath(); 320 | this.ctx.moveTo(posX, posY); 321 | this.ctx.lineTo(posX + velX * 4, posY + velY * 4); 322 | this.ctx.stroke(); 323 | } 324 | } 325 | 326 | renderPickables() { 327 | for (const pickable of this.engine.level_.pickables) { 328 | if (!pickable.collected) { 329 | switch (pickable.type) { 330 | case PickableType.crystal: 331 | this.renderCrystal(pickable, 12, false); 332 | break; 333 | case PickableType.gravityCrystal: 334 | this.renderCrystal(pickable, 8, true); 335 | break; 336 | case PickableType.bubble: 337 | this.renderBubble(pickable.pos); 338 | break; 339 | } 340 | } 341 | } 342 | } 343 | 344 | renderBubble(pos: Vector2, shouldAnimate = true) { 345 | this.ctx.save(); 346 | this.ctx.translate( 347 | pos.x, 348 | pos.y + (shouldAnimate ? Math.sin(this.engine.time_ / 400) * 3 : 0), 349 | ); 350 | if (shouldAnimate) { 351 | this.ctx.scale( 352 | 1 + Math.sin(this.engine.time_ / 230) * 0.05, 353 | 1 + Math.sin(this.engine.time_ / 280) * 0.05, 354 | ); 355 | } 356 | 357 | const grd = this.ctx.createRadialGradient(0, 0, 0, 0, 0, 22); 358 | grd.addColorStop(0, "#8883"); 359 | grd.addColorStop(0.65, "#8888"); 360 | grd.addColorStop(0.95, "#888a"); 361 | grd.addColorStop(1, "#8880"); 362 | this.ctx.fillStyle = grd; 363 | 364 | this.ctx.beginPath(); 365 | this.ctx.fillRect(-22, -22, 44, 44); 366 | this.ctx.closePath(); 367 | 368 | this.ctx.fillStyle = "#ccc8"; 369 | this.ctx.beginPath(); 370 | this.ctx.arc(8, -8, 5, 0, Math.PI * 2); 371 | this.ctx.closePath(); 372 | this.ctx.fill(); 373 | 374 | this.ctx.restore(); 375 | } 376 | 377 | renderCrystal(crystal: Pickable, height: number, isGreen: boolean) { 378 | this.ctx.save(); 379 | this.ctx.translate( 380 | crystal.pos.x, 381 | crystal.pos.y + Math.sin(this.engine.time_ / 800) * 5, 382 | ); 383 | for (let i = 0; i < 4; i++) { 384 | const time = 385 | (this.engine.time_ + i * Math.PI * 125) % ((Math.PI / 2) * 1000); 386 | const cos = Math.cos(time / 1000); 387 | const sin = Math.sin(time / 1000); 388 | this.ctx.scale(1, -1); 389 | this.renderCrystalPart(sin, cos, 1, height, isGreen); 390 | this.ctx.scale(1, -1); 391 | this.renderCrystalPart(sin, cos, 0.6, height, isGreen); 392 | } 393 | this.ctx.globalCompositeOperation = "screen"; 394 | this.renderLight(new Vector2(0, 0), isGreen ? "#001" : "#200", 25); 395 | this.ctx.globalCompositeOperation = "source-over"; 396 | this.ctx.restore(); 397 | } 398 | 399 | renderCrystalPart( 400 | sin: number, 401 | cos: number, 402 | colorDarkening: number, 403 | height: number, 404 | isGreen: boolean, 405 | ) { 406 | const color = this.toHexColor(50 + cos * 180 * colorDarkening); 407 | const color2 = this.toHexColor(cos * 120 * colorDarkening); 408 | this.ctx.fillStyle = isGreen 409 | ? `#${color2}${color2}${color}` 410 | : `#${color}${color2}${color2}`; 411 | this.ctx.beginPath(); 412 | this.ctx.lineTo(8 - sin * 16, 0); 413 | this.ctx.lineTo(0, height); 414 | this.ctx.lineTo(cos * 16 - 8, 0); 415 | this.ctx.closePath(); 416 | this.ctx.fill(); 417 | } 418 | 419 | toHexColor(color: number) { 420 | const c = Math.round(color).toString(16); 421 | if (c.length === 1) { 422 | return "0" + c; 423 | } 424 | return c; 425 | } 426 | 427 | renderhangman() { 428 | this.ctx.drawImage(assets.scaffold, 80, 170, 80, 160); 429 | 430 | this.ctx.save(); 431 | this.ctx.translate(130, 175); 432 | this.ctx.rotate(Math.sin(this.engine.time_ / 450) / 25 - 0.1); 433 | this.ctx.drawImage(assets.hangman, -15, 0, 80, 120); 434 | this.ctx.restore(); 435 | } 436 | 437 | renderGraves() { 438 | this.ctx.lineWidth = 5; 439 | this.ctx.strokeStyle = "#000"; 440 | let x = 1090; 441 | while (x < this.engine.level_.size_.x) { 442 | this.ctx.save(); 443 | this.ctx.translate(x, 310); 444 | 445 | this.ctx.beginPath(); 446 | this.ctx.arc(0, 0, 25, Math.PI, Math.PI * 2); 447 | this.ctx.closePath(); 448 | this.ctx.fill(); 449 | 450 | this.ctx.beginPath(); 451 | this.ctx.moveTo(0, 0); 452 | this.ctx.lineTo(0, -80); 453 | this.ctx.stroke(); 454 | 455 | this.ctx.beginPath(); 456 | this.ctx.moveTo(-15, -65); 457 | this.ctx.lineTo(15, -65); 458 | this.ctx.stroke(); 459 | 460 | this.ctx.restore(); 461 | 462 | x += 80; 463 | } 464 | } 465 | 466 | renderLevelTransitions() { 467 | if (this.engine.levelTransitionEnter || this.engine.levelTransitionLeave) { 468 | const d = 469 | this.engine.time_ - 470 | Math.max( 471 | this.engine.levelTransitionEnter, 472 | this.engine.levelTransitionLeave, 473 | ); 474 | 475 | const diagonal = Math.sqrt( 476 | this.ctx.canvas.width * this.ctx.canvas.width + 477 | this.ctx.canvas.height * this.ctx.canvas.height, 478 | ); 479 | 480 | let r = diagonal * (d / 1100); 481 | if (this.engine.levelTransitionLeave) { 482 | r = diagonal - r; 483 | } 484 | this.ctx.globalCompositeOperation = "destination-in"; 485 | this.ctx.beginPath(); 486 | this.ctx.arc( 487 | this.engine.player.body_.pos.x, 488 | this.engine.player.body_.pos.y, 489 | Math.max(0, r), 490 | 0, 491 | Math.PI * 2, 492 | ); 493 | this.ctx.closePath(); 494 | this.ctx.fill(); 495 | this.ctx.globalCompositeOperation = "source-over"; 496 | } 497 | } 498 | 499 | prerender() { 500 | this.skyLayer.activate(); 501 | this.renderSky(); 502 | 503 | this.terrainLayer.activate(); 504 | this.ctx.fillStyle = "#000"; 505 | this.renderSpikes(); 506 | this.renderPlatforms(); 507 | this.renderTerrain(); 508 | 509 | const hillsParams: [string, number, number, number, number][] = [ 510 | ["#282828", 500, 0.5, 1300, 3], 511 | ["#222", 400, 0.7, 1000, 7], 512 | ["#1d1d1d", 200, 1.0, 800, 9], 513 | ]; 514 | for (const [index, hillsLayer] of this.hillsLayers.entries()) { 515 | hillsLayer.activate(); 516 | this.renderHills(...hillsParams[index]); 517 | } 518 | } 519 | 520 | render() { 521 | const pos = this.engine.camera.pos; 522 | 523 | this.baseLayer.activate(); 524 | this.renderLayer(this.skyLayer); 525 | for (const hillsLayer of this.hillsLayers) { 526 | this.renderLayer(hillsLayer); 527 | } 528 | 529 | this.ctx.translate(-pos.x, -pos.y); 530 | this.renderFoliage(false); 531 | 532 | if (this.engine.currentSave.level_ === 0) { 533 | this.renderhangman(); 534 | } 535 | 536 | if (this.engine.currentSave.level_ === LEVELS.length - 1) { 537 | this.renderGraves(); 538 | } 539 | 540 | this.ctx.translate(pos.x, pos.y); 541 | 542 | this.renderRain(); 543 | 544 | this.renderLayer(this.terrainLayer); 545 | 546 | this.ctx.translate(-pos.x, -pos.y); 547 | 548 | if (!this.engine.player.isDead) { 549 | this.renderPlayer(); 550 | } 551 | this.renderPickables(); 552 | 553 | const points = this.engine.foliage.findGround( 554 | this.engine, 555 | this.engine.player.body_.pos.x, 556 | GRASS_MASK, 557 | ); 558 | for (const p of points) { 559 | this.ctx.fillRect(p.x - 1, p.y - 1, 2, 2); 560 | } 561 | 562 | this.renderParticles(); 563 | this.renderFoliage(true); 564 | 565 | if (!this.engine.player.isDead) { 566 | this.ctx.globalCompositeOperation = "screen"; 567 | let color = "#333"; 568 | const antigravityTime = this.engine.player.physics.antigravityTime; 569 | const diff = this.engine.time_ - antigravityTime; 570 | if (antigravityTime) { 571 | if (diff > 2500) { 572 | color = Math.sin(diff / 50) > 0 ? "#005" : color; 573 | } else { 574 | color = "#005"; 575 | } 576 | } 577 | this.renderLight(this.engine.player.body_.pos, color, 50); 578 | this.ctx.globalCompositeOperation = "source-over"; 579 | } 580 | 581 | this.renderLevelTransitions(); 582 | 583 | this.ctx.translate(pos.x, pos.y); 584 | } 585 | 586 | updateSize() { 587 | this.engine.canvas_.width = window.outerWidth * 0.4; 588 | this.engine.canvas_.height = window.outerHeight * 0.4; 589 | 590 | for (const layer of Layer.layers) { 591 | layer.updateSize(); 592 | } 593 | this.prerender(); 594 | } 595 | 596 | renderLayer(layer: Layer) { 597 | if (layer.offset_) { 598 | this.drawLayerWithCameraOffset(layer); 599 | } else { 600 | this.ctx.drawImage(layer.canvas_, 0, 0); 601 | } 602 | } 603 | 604 | drawLayerWithCameraOffset(layer: Layer) { 605 | this.ctx.drawImage( 606 | layer.canvas_, 607 | this.engine.camera.pos.x * layer.offsetScale, 608 | this.engine.camera.pos.y * (layer.offsetScale === 1 ? 1 : 0), 609 | this.activeLayer.canvas_.width, 610 | this.activeLayer.canvas_.height, 611 | 0, 612 | 0, 613 | this.activeLayer.canvas_.width, 614 | this.activeLayer.canvas_.height, 615 | ); 616 | } 617 | } 618 | -------------------------------------------------------------------------------- /src/renderer/sprite-renderer.ts: -------------------------------------------------------------------------------- 1 | export class SpriteRenderer { 2 | ctx!: CanvasRenderingContext2D; 3 | private canvas_!: HTMLCanvasElement; 4 | constructor() { 5 | this.canvas_ = document.createElement("canvas"); 6 | this.ctx = this.canvas_.getContext("2d")!; 7 | } 8 | 9 | setSize(width: number, height: number) { 10 | this.canvas_.width = width; 11 | this.canvas_.height = height; 12 | } 13 | 14 | async render( 15 | renderFn: (ctx: CanvasRenderingContext2D) => void, 16 | ): Promise { 17 | return new Promise((resolve, reject) => { 18 | renderFn(this.ctx); 19 | const img = new Image(); 20 | img.onload = () => resolve(img); 21 | img.src = this.canvas_.toDataURL(); 22 | this.reset_(); 23 | }); 24 | } 25 | 26 | private reset_() { 27 | this.ctx.clearRect(0, 0, this.canvas_.width, this.canvas_.height); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/saves.ts: -------------------------------------------------------------------------------- 1 | import { Vector2 } from "./vector"; 2 | 3 | export interface Save { 4 | level_: number; 5 | pos: { x: number; y: number } | null; 6 | 7 | // maps a level id to an array of collected crystals ids 8 | crystals: { [key: number]: number[] }; 9 | } 10 | 11 | export function loadSave(): Save { 12 | // JSON.parse returns null when provided with null 13 | return ( 14 | JSON.parse(localStorage.getItem("tww_s")!) || { 15 | level_: 0, 16 | pos: null, 17 | crystals: {}, 18 | } 19 | ); 20 | } 21 | 22 | export function save_(save: Save) { 23 | localStorage.setItem("tww_s", JSON.stringify(save)); 24 | } 25 | 26 | export function clearSave() { 27 | localStorage.removeItem("tww_s"); 28 | } 29 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function lerp(p1: number, p2: number, t: number) { 2 | return (1 - t) * p1 + t * p2; 3 | } 4 | -------------------------------------------------------------------------------- /src/vector.ts: -------------------------------------------------------------------------------- 1 | export class Vector2 { 2 | constructor(public x = 0, public y = 0) {} 3 | 4 | copy() { 5 | return new Vector2(this.x, this.y); 6 | } 7 | 8 | zero() { 9 | this.x = 0; 10 | this.y = 0; 11 | return this; 12 | } 13 | 14 | rotate_(angle: number) { 15 | const nx = this.x * Math.cos(angle) - this.y * Math.sin(angle); 16 | const ny = this.x * Math.sin(angle) + this.y * Math.cos(angle); 17 | 18 | this.x = nx; 19 | this.y = ny; 20 | 21 | return this; 22 | } 23 | 24 | add_(vec: Vector2) { 25 | this.x += vec.x; 26 | this.y += vec.y; 27 | return this; 28 | } 29 | 30 | sub_(vec: Vector2) { 31 | this.x -= vec.x; 32 | this.y -= vec.y; 33 | return this; 34 | } 35 | 36 | normalize_() { 37 | const length = Math.sqrt(this.x * this.x + this.y * this.y); 38 | if (!length) { 39 | return this.zero(); 40 | } 41 | this.x /= length; 42 | this.y /= length; 43 | return this; 44 | } 45 | 46 | mul(scalar: number) { 47 | this.x *= scalar; 48 | this.y *= scalar; 49 | return this; 50 | } 51 | 52 | dot(vec: Vector2) { 53 | return this.x * vec.x + this.y * vec.y; 54 | } 55 | 56 | length_() { 57 | return Math.sqrt(this.x * this.x + this.y * this.y); 58 | } 59 | 60 | angle_() { 61 | return Math.PI - Math.atan2(-this.x, -this.y); 62 | } 63 | 64 | distanceTo(v: Vector2) { 65 | return Math.sqrt(Math.pow(this.x - v.x, 2) + Math.pow(this.y - v.y, 2)); 66 | } 67 | 68 | directionTo(v: Vector2) { 69 | return Math.PI - Math.atan2(this.x - v.x, this.y - v.y); 70 | } 71 | 72 | angleTo(vec: Vector2) { 73 | return Math.acos(this.dot(vec) / (this.length_() * vec.length_())) || 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "noImplicitAny": true, 5 | "sourceMap": true, 6 | "outDir": "dist", 7 | "strictNullChecks": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "composer" 12 | ] 13 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const HtmlWebpackInlineSVGPlugin = require("html-webpack-inline-svg-plugin"); 5 | const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin"); 6 | const ExtraWatchWebpackPlugin = require("extra-watch-webpack-plugin"); 7 | const ZipPlugin = require("zip-webpack-plugin"); 8 | const TerserPlugin = require("terser-webpack-plugin"); 9 | 10 | module.exports = env => { 11 | const isProd = env === "prod"; 12 | 13 | process.env["NODE_ENV"] = isProd ? "production" : "development"; 14 | 15 | const plugins = [ 16 | new HtmlWebpackPlugin({ 17 | template: "src/index.html", 18 | minify: { 19 | removeComments: true, 20 | removeAttributeQuotes: true, 21 | removeScriptTypeAttributes: true, 22 | removeTagWhitespace: true, 23 | collapseWhitespace: true, 24 | minifyCSS: true, 25 | inlineSource: ".(js|ts)$", 26 | }, 27 | svgoConfig: { 28 | removeViewBox: true, 29 | removeDimensions: false, 30 | convertTransform: true, 31 | cleanupNumericValues: { 32 | floatPrecision: 1, 33 | }, 34 | convertPathData: { 35 | floatPrecision: 1, 36 | }, 37 | }, 38 | }), 39 | new HtmlWebpackInlineSVGPlugin({ 40 | runPreEmit: true, 41 | }), 42 | new ExtraWatchWebpackPlugin({ 43 | files: ["levels/*.svg", "assets/*.svg"], 44 | }), 45 | ]; 46 | 47 | if (isProd) { 48 | plugins.push( 49 | new ScriptExtHtmlWebpackPlugin({ 50 | inline: "bundle", 51 | }), 52 | ); 53 | plugins.push(new ZipPlugin({ filename: "bundle.zip" })); 54 | } 55 | 56 | return { 57 | entry: "./src/index.ts", 58 | module: { 59 | rules: [ 60 | { 61 | test: /\.ts?$/, 62 | use: ["ts-loader", "webpack-conditional-loader"], 63 | exclude: /node_modules/, 64 | }, 65 | ], 66 | }, 67 | resolve: { 68 | extensions: [".ts", ".js"], 69 | }, 70 | output: { 71 | filename: "bundle.js", 72 | path: path.resolve(__dirname, "dist"), 73 | }, 74 | plugins: plugins, 75 | devtool: isProd ? false : "source-map", 76 | optimization: { 77 | minimizer: [ 78 | new TerserPlugin({ 79 | terserOptions: { 80 | mangle: { 81 | properties: { 82 | keep_quoted: true, 83 | }, 84 | }, 85 | }, 86 | }), 87 | ], 88 | }, 89 | }; 90 | }; 91 | 92 | console.log("process.env.NODE_ENV", process.env.NODE_ENV); 93 | --------------------------------------------------------------------------------