diff --git a/.gitignore b/.gitignore
index e43b0f98..671a9b68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
+node_modules/
+build*/
.DS_Store
diff --git a/gulpfile.js b/gulpfile.js
new file mode 100644
index 00000000..ea523a73
--- /dev/null
+++ b/gulpfile.js
@@ -0,0 +1,51 @@
+const { src, dest } = require('gulp');
+const webpack = require('webpack-stream');
+const TerserPlugin = require('terser-webpack-plugin');
+
+function defaultTask(cb) {
+ // place code for your default task here
+
+ src(['modules/*.js'])
+ .pipe(webpack({
+ // Any configuration options...
+ output: {
+ filename: 'bundle.js'
+ },
+ optimization: {
+ minimizer: [
+ new TerserPlugin({
+ cache: true,
+ parallel: true,
+ sourceMap: true, // Must be set to true if using source-maps in production
+ terserOptions: {
+ // https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions
+ ecma: 6,
+ warnings: true,
+ parse: {},
+ compress: {},
+ mangle: {
+ properties: {
+ reserved: ["keyIsDown", "clamp", "iterateQGrid"], // for some reason webpack cant handle these exports
+ // debug: "",
+ undeclared: true,
+ }
+ }, // Note `mangle.properties` is `false` by default.
+ module: false,
+ output: null,
+ toplevel: false,
+ nameCache: null,
+ ie8: false,
+ keep_classnames: undefined,
+ keep_fnames: false,
+ safari10: false,
+ }
+ }),
+ ],
+ }
+ }))
+ .pipe(dest('build'))
+
+ cb();
+}
+
+exports.default = defaultTask
\ No newline at end of file
diff --git a/images/spritesheet.png b/images/spritesheet.png
new file mode 100644
index 00000000..442ff63d
Binary files /dev/null and b/images/spritesheet.png differ
diff --git a/index.html b/index.html
index e69de29b..a5d65491 100644
--- a/index.html
+++ b/index.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
CONGRATULATIONS!!!
+
You made it to
+
LEVEL
+
+
+
+
+
+
PIZZA UNDELIVERY
+
In this game you have to pick up the pizzas and bring them back to the pizzeria!
+
Drive with the arrow keys or the WASD keys.
+
LEVEL:
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
index 391efcfa..29ec7279 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,14 +1,14 @@
{
- "name": "Title of the game",
- "description": "Description of the game, including controls and optional extras for Coil subscribers if you participate in the Web Monetization category.",
+ "name": "Pizza Undelivery",
+ "description": "Here you bring pizzas back to the pizzeria, instead of the other way around. This retro style racing game is about navigating a randomly generated 3D city in your 1986 Toyota Corolla, while listening to hand-coded floatbeat music and sound effects. Another highlight is the custom 3D rendering engine implemented in JavaScript (that might not work on slower machines, sorry).",
"images" : {
- "image_large": "image-big.jpg",
- "image_thumbnail": "image-small.jpg"
+ "image_large": "promo_images/image_large.png",
+ "image_thumbnail": "promo_images/image_thumbnail.png"
},
- "categories": ["Desktop", "Mobile", "Server", "Web Monetization", "WebXR"],
+ "categories": ["Desktop"],
"user" : {
- "name": "optional",
- "website": "optional",
- "twitter": "optional"
+ "name": "Nils Gawlik",
+ "website": "nilsgawlik.github.io",
+ "twitter": "@nilsgawlik"
}
}
diff --git a/modules/audio.js b/modules/audio.js
new file mode 100644
index 00000000..f41358a4
--- /dev/null
+++ b/modules/audio.js
@@ -0,0 +1,191 @@
+let random = Math.random;
+let sign = Math.sign;
+let sin = Math.sin;
+let max = Math.max;
+let atan = Math.atan;
+const PI = Math.PI;
+
+export default class Audio {
+ constructor() {
+ // Create an audio context
+ this.ctx = new AudioContext();
+ this.master = this.ctx.createGain();
+ this.master.gain.setValueAtTime(0.5, this.ctx.currentTime); // keep it civil!
+ this.master.connect(this.ctx.destination);
+ }
+
+ playCrash(volume = 1) {
+ const srate = 8000;
+ const length = srate*1;
+ let buffer = this.ctx.createBuffer(1, length, srate);
+ let data = buffer.getChannelData(0);
+ let p1 = 90 + random() * 64;
+
+ for(let t = 0; t < length; t++) {
+ let tt = t;
+ t = t & ~(1 << ~~(t/p1));
+ let env = (1- t / length);
+ let env2 = (1- t / 2000);
+ // const env = 1-(t % (1<<12) / (1<<12));
+ let kick = sin(env**32*0x5f);
+ data[tt] = atan(
+ kick * env2**5
+ ) / (PI/2) * volume
+ ;
+ t = tt;
+ }
+
+ let source = this.ctx.createBufferSource();
+ source.buffer = buffer;
+ // source.loop = true;
+
+ source.connect(this.master);
+ source.start();
+ }
+
+ playCollect(volume = 1) {
+ const srate = 8000;
+ const length = 8000;
+ let buffer = this.ctx.createBuffer(1, length, srate);
+ let data = buffer.getChannelData(0);
+ let p1 = 1 + random();
+
+ for(let t = 0; t < length; t++) {
+ let tt = t;
+ // t = t & ~(1 << ~~(t/p1));
+ let env = (1- t / 8000);
+ data[tt] = atan(
+ sin(t * .02 * p1 * (t>>9) * (t>>10))
+ ) / (PI/2) * 2 * volume * env**2
+ ;
+ t = tt;
+ }
+
+ let source = this.ctx.createBufferSource();
+ source.buffer = buffer;
+ // source.loop = true;
+
+ source.connect(this.master);
+ source.start();
+ }
+
+
+ playLevelFinish(volume = 1) {
+ const srate = 8000;
+ const length = srate*2;
+ let buffer = this.ctx.createBuffer(1, length, srate);
+ let data = buffer.getChannelData(0);
+ let p1 = 1 + random();
+
+ for(let t = 0; t < length; t++) {
+ let tt = t;
+ // t = t & ~(1 << ~~(t/p1));
+ let env = (1 - t / length);
+ const env2 = 1-(t % (1<<10) / (1<<10));
+ data[tt] = atan(
+ sin(t * .05 * p1 * 2**((t>>10)%3)) * sin(env2)**3 * env**3
+ +
+ sin(t * .05 * p1) * env**5
+ ) / (PI/2) * 2 * volume
+ +
+ data[max(tt-1000, 0)] * 0.4
+ ;
+ t = tt;
+ }
+
+ let source = this.ctx.createBufferSource();
+ source.buffer = buffer;
+ // source.loop = true;
+
+ source.connect(this.master);
+ source.start();
+ }
+
+
+ start() {
+
+ const srate = 12000;
+ const length = 1 << 20;
+ let musicBuffer = this.ctx.createBuffer(1, length, srate);
+ let data = musicBuffer.getChannelData(0);
+
+ let flavor;
+
+ let b = 0;
+ for(let i = 0; i < 5; i++) {
+ b = b | ~~(random() * 4);
+ b = b << 2;
+ }
+ // look at those magic numbers!
+ let bs = [
+ 773752, // very fast upbeat
+ 773752, // very fast upbeat
+ 773752|0x7f, // very fast upbeat
+ 957758312, // fast ish
+
+ 773752, // very fast upbeat
+ 773752, // very fast upbeat
+ 773752|0x7f, // very fast upbeat
+ 957758312, // fast ish
+
+ 792323628, // noisy? slow
+ 792323628, // noisy? slow
+ 957758312, // fast ish
+ 957758312, // fast ish
+
+ 1008437436, // fast ish synth
+ 1008437436, // fast ish synth
+ 773752|0x7f, // very fast upbeat
+ 957758312, // fast ish
+
+ // -409420504, // interesting medium
+ // 792323628,// noisy? slow
+ // 345849296, // continuus subbase
+ // 1962361744, // fast tension
+ ];
+ // let bs = [345849296,1962361744,957758312,792323628,773752,1008437436,773752,792323628,773752,792323628];
+
+ for(let t = 0; t < length; t++) {
+ const env = 1-(t % (1<<12) / (1<<12));
+ const env2 = 1-(t % (1<<15) / (1<<15));
+ const env3 = 1-(t % (1<<10) / (1<<10));
+ let hihat1 = (random()*2-1)*env**32 * 0.5;
+ let hihat2 = (random()*2-1)*env3**32 * 1.9;
+ let kick = sin(env**6*0x6f)*env;
+ let snare = (random()*2-1)*env**4;
+ if(t % (1<<15) == 0) flavor = ~~(random() * 2048);
+ if(t % (1<<16) == 0) {
+ // this is how magic numbers are originally made!
+ // for(let i = 0; i < 5; i++) {
+ // b = b | ~~(random() * 4);
+ // b = b << 2;
+ // }
+ // bs.push(b);
+ b = bs.shift();
+ };
+ let tt = t;
+ t = t & b;
+ data[tt] =
+ [[kick,hihat1,snare,hihat1][(t>>12)%4] * 0.4, [kick,snare,hihat1,hihat2][(t>>12)%4] * 0.4][max((t>>14)%4-2, 0)]
+ + sign(sin((t&((1<<(5-(t>>14)%3+(t>>12)%6))-1))*(1/64)*PI*(1+env2*.25))) * 0.05
+ + sign(sin((t&(flavor << 4))*(1/64)*PI)) * 0.05
+ // + sign(sin((t&([32,2,3,111][(t>>15)%4] << 4))*(1/64)*PI)) * 0.1
+ + data[max(0, t - 2100)] * 0.4
+ + data[max(0, t - 2000)] * 0.4
+ ;
+ t = tt;
+ }
+
+ console.log(bs.toString());
+
+ let musicSource = this.ctx.createBufferSource();
+ musicSource.buffer = musicBuffer;
+ musicSource.loop = true;
+
+ musicSource.connect(this.master);
+ musicSource.start();
+ }
+
+ update() {
+ }
+}
\ No newline at end of file
diff --git a/modules/game.js b/modules/game.js
new file mode 100644
index 00000000..949ca6ae
--- /dev/null
+++ b/modules/game.js
@@ -0,0 +1,303 @@
+import Generator from "./generator.js";
+import {iterateQGrid} from "./generator.js";
+import Renderer from "./renderer.js";
+import { keyIsDown } from "./keyHandler.js";
+import Audio from "./audio.js";
+
+const ACC = .25 / 30;
+const STEER = 2 / 30;
+const TRACT = 0.1;
+const FRIC = 0.1;
+const BACKFRIC = 0.3;
+
+const DEBUG = false; // TODO flip
+
+const MAP_SIZE = 32;
+const START_TIME = 60;
+const TIME_INCREASE = 50;
+
+class Car {
+ constructor(renderer, generator) {
+ this.x = 0;
+ this.y = 0;
+ this.r = .03;
+ this.hspd = 0;
+ this.vspd = 0;
+ this.angle = 0;
+
+ this.renderer = renderer;
+ this.generator = generator;
+ }
+
+ update(isPaused) {
+ if(!isPaused) {
+
+ let dx, dy, scl;
+ const acceleration = (keyIsDown("up") - keyIsDown("down")) * ACC;
+ let steering = (keyIsDown("right") - keyIsDown("left"));
+ this.renderer.carFrame = -steering+1;
+ steering *= STEER;
+
+ dx = Math.cos(this.angle);
+ dy = Math.sin(this.angle);
+ scl = Math.max(Math.min((dx * this.hspd + dy * this.vspd) * 60, 1), -1);
+
+ this.angle += steering * scl;
+ this.hspd += Math.cos(this.angle) * acceleration;
+ this.vspd += Math.sin(this.angle) * acceleration;
+ this.x += this.hspd;
+ this.y += this.vspd;
+
+ // friction
+ // sideways
+ dx = -Math.sin(this.angle);
+ dy = Math.cos(this.angle);
+ scl = (dx * this.hspd + dy * this.vspd) * TRACT;
+ this.hspd -= scl * dx;
+ this.vspd -= scl * dy;
+
+ // forw. backw.
+ dx = Math.cos(this.angle);
+ dy = Math.sin(this.angle);
+ scl = (dx * this.hspd + dy * this.vspd);
+ scl *= scl > 0? FRIC : BACKFRIC;
+ this.hspd -= scl * dx;
+ this.vspd -= scl * dy;
+
+ //collision
+ this.handleCircleCollision(this);
+ }
+
+ // render
+ this.renderer.x = this.x - Math.cos(this.angle) * .25;
+ this.renderer.y = this.y - Math.sin(this.angle) * .25;
+ this.renderer.z = 0.25 + this.generator.sampleGroundHeightmap(this.x, this.y);
+ this.renderer.angle = this.angle;
+ }
+
+ handleCircleCollision(obj) {
+ if(this.collisionAtPoint(obj.x, obj.y)) {
+ obj.vspd *= -1;
+ obj.hspd *= -1;
+ let spd = Math.sqrt(obj.hspd ** 2 + obj.vspd ** 2);
+ if(spd > 0.01) GameManager.inst.audio.playCrash((Math.abs(spd) - 0.01)*20);
+ }
+
+ const x = Math.round(obj.x);
+ const y = Math.round(obj.y);
+ const rx = obj.x - x;
+ const ry = obj.y - y;
+ const posX = rx > 0? 1 : 0;
+ const negX = 1 - posX;
+ const posY = ry > 0? 1 : 0;
+ const negY = 1 - posY;
+
+ const l = Math.sqrt(rx*rx + ry*ry);
+
+ const colX = Math.abs(rx) < obj.r && this.collisionAtPoint(x - posX, y - negY);
+ const colY = Math.abs(ry) < obj.r && this.collisionAtPoint(x - negX, y - posY);
+ const colXY = !colX && !colY && l < obj.r && this.collisionAtPoint(x - posX, y - posY);
+
+ // document.querySelector("footer>p").innerHTML = `
+ // rx: ${String(rx).slice(0, 4)}
+ // ry: ${String(ry).slice(0, 4)}
+ // colX: ${colX}
+ // colY: ${colY}
+ // colXY: ${colXY}
+ // x: ${String(this.x).slice(0, 4)}
+ // y: ${String(this.y).slice(0, 4)}`;
+
+ if(colXY && l > 0.01) {
+ // corner collision
+ obj.x = x + rx*obj.r/l;
+ obj.y = y + ry*obj.r/l;
+ } else {
+ if(colX) {
+ obj.x = x + Math.sign(rx) * obj.r;
+ obj.hspd *= -1;
+ if(Math.abs(obj.hspd) > .01) GameManager.inst.audio.playCrash((Math.abs(obj.hspd) - 0.01) * 20);
+ }
+ if(colY) {
+ obj.y = y + Math.sign(ry) * obj.r;
+ obj.vspd *= -1;
+ if(Math.abs(obj.vspd) > .01) GameManager.inst.audio.playCrash((Math.abs(obj.hspd) - 0.01) * 20);
+ }
+ }
+
+ }
+
+ collisionAtPoint(x, y) {
+ return this.generator.sampleFloorMap(x, y) != 1;
+ }
+}
+
+class GameManager {
+ constructor(renderer, generator, car, audio) {
+ this.level = 1;
+ this.pizzasCollected = 0;
+ this.pizzasToCollect = 0;
+ this.renderer = renderer;
+ this.generator = generator;
+ this.car = car;
+ this.audio = audio;
+ this.pizzas = [];
+ this.startObj = null;
+ this.time = 0;
+ this.isPaused = false;
+ this.overlay = null;
+
+ GameManager.inst = this;
+ }
+
+ resetGame() {
+ this.time = START_TIME;
+ this.car.angle = this.car.vspd = this.car.hspd = 0;
+ this.isPaused = true;
+ this.loadOverlay("title-template");
+ this.startObj = null;
+ this.startLevel();
+ let buttons = this.overlay.querySelectorAll("button");
+ buttons[0].onclick = (ev) => {
+ this.generator.generate();
+ this.renderer.renderFloorTexture();
+ this.renderer.renderMinimap();
+ this.startObj = null;
+ this.startLevel();
+ };
+ buttons[1].onclick = (ev) => {
+ this.level = Number(this.overlay.querySelector("input").value);
+ this.overlay.remove();
+ this.overlay = null;
+ this.isPaused = false;
+ this.startLevel();
+ };
+ }
+
+ startLevel() {
+ // start of level
+ this.pizzasToCollect = this.level;
+ this.pizzasCollected = 0;
+ this.placeObjects();
+ this.renderer.setText("Collect the pizzas!", "red");
+ }
+
+ placeObjects() {
+ this.pizzas = [];
+ let positions = [];
+ // for(let p of iterateQGrid(MAP_SIZE/4, MAP_SIZE/4)) {
+ for(let p of iterateQGrid(MAP_SIZE, MAP_SIZE)) {
+ p.x += 0.5;
+ p.y += 0.5;
+ positions.push(p);
+ }
+ // only use streets
+ positions = positions.filter(p => this.generator.sampleFloorMap(p.x, p.y) == 1);
+
+ // select a few random positions for pizzas
+ for(let i = 0; i < this.pizzasToCollect; i++) {
+ let pizza = positions.splice(~~(Math.random() * positions.length), 1)[0];
+ pizza.spriteIndex = 0;
+ this.pizzas.push(pizza);
+ }
+
+ if(!this.startObj) {
+ // select the start position
+ this.startObj = positions.splice(~~(Math.random() * positions.length), 1)[0];
+ this.startObj.spriteIndex = 1;
+ this.car.x = this.startObj.x;
+ this.car.y = this.startObj.y;
+ }
+
+ this.renderer.pizzaPositions = this.pizzas;
+ }
+
+ update() {
+ // update timer
+ if(!this.isPaused) this.time -= 1/30; // just rely on fixed framerate
+ this.time = Math.max(this.time, 0);
+ this.renderer.timeLeft = Math.floor(this.time);
+
+ if((this.time == 0 || keyIsDown("exit")) && !this.overlay) {
+ // game over screen
+ this.isPaused = true;
+ // dynamic content
+ this.loadOverlay("game-over-template");
+ this.overlay.querySelector("#lnum").append(`${this.level}`);
+ // callbacks
+ this.overlay.querySelector("button").onclick = () => {
+ this.overlay.remove();
+ this.overlay = null;
+ this.isPaused = false;
+ this.resetGame();
+ }
+ }
+
+ // check collection of checkpoints pizzas etc.
+ if(this.pizzas.length > 0) {
+ for(let pizza of this.pizzas) {
+ if((pizza.x - this.car.x)**2 + (pizza.y - this.car.y)**2 < 0.3**2) {
+ pizza.collected = true;
+ this.audio.playCollect();
+ this.pizzasCollected++;
+ this.renderer.setSuperText(`${this.pizzasCollected} / ${this.pizzasToCollect} pizzas\ncollected!!!`)
+ }
+ }
+ this.pizzas = this.pizzas.filter(p => !p.collected);
+ this.renderer.pizzaPositions = this.pizzas;
+ } else {
+ this.renderer.setText("Return to start!", "yellow")
+ this.renderer.pizzaPositions = [this.startObj];
+ if((this.startObj.x - this.car.x)**2 + (this.startObj.y - this.car.y)**2 < 0.4**2) {
+ this.level++;
+ this.renderer.setSuperText(`LEVEL ${this.level}`);
+ this.time += TIME_INCREASE;
+ this.audio.playLevelFinish();
+ this.startLevel();
+ }
+ }
+ }
+
+ loadOverlay(templateID) {
+ this.overlay = document.querySelector(`#${templateID}`).content.cloneNode(true).firstElementChild;
+ let main = document.querySelector("main");
+ main.insertBefore(this.overlay, document.querySelector("main>.game"));
+ // handle resizing
+ let func = (ev) => {
+ this.overlay.setAttribute("style",`width:${~~(main.offsetWidth)}px; height:${~~(main.offsetHeight)}px`);
+ };
+ window.onresize = func;
+ func();
+ }
+}
+
+window.addEventListener("load", () => {
+ startGame();
+});
+
+async function startGame() {
+ let audio = new Audio();
+ // TODO uncomment in final build
+ if(DEBUG)
+ {
+ audio.start();
+ } else {
+ let body = document.querySelector("body");
+ body.onkeydown = ev => {
+ audio.start();
+ body.onkeydown = null;
+ };
+ }
+ let gen = new Generator(MAP_SIZE);
+ let rend = new Renderer(gen, document.querySelector("canvas.game"));
+ await rend.load();
+ let car = new Car(rend, gen);
+ let gm = new GameManager(rend, gen, car, audio);
+ gm.resetGame();
+
+ window.setInterval(() => {
+ gm.update();
+ car.update(gm.isPaused);
+ audio.update(gm.isPaused);
+ rend.render(gm.isPaused);
+ }, 1000/30)
+}
\ No newline at end of file
diff --git a/modules/generator.js b/modules/generator.js
new file mode 100644
index 00000000..0d8658e0
--- /dev/null
+++ b/modules/generator.js
@@ -0,0 +1,198 @@
+
+const COS = [1, 0, -1, 0];
+const SIN = [0, 1, 0, -1];
+
+class NoiseLayer {
+ constructor(w, h) {
+ this.width = w;
+ this.height = h;
+ this.patternTree = {0:{0:{0:{0:{0:{0:{0:{0:{0:{score:1}}},1:{1:{1:{score:1}}}}},1:{0:{0:{0:{0:{score:-1}}}}}},1:{1:{1:{1:{1:{1:{score:-1}}}}}}},1:{0:{0:{1:{0:{0:{1:{score:1}}},1:{1:{1:{score:1}}}}}}}},1:{1:{0:{1:{1:{0:{1:{1:{score:-1}}}}}}}}},1:{0:{0:{1:{0:{0:{1:{0:{0:{score:1}},1:{1:{score:1}}}}}}}},1:{0:{1:{1:{0:{1:{1:{0:{score:-1}}}}}}},1:{0:{0:{0:{0:{0:{0:{score:1}}}},1:{0:{0:{1:{score:1}}}}}},1:{0:{0:{1:{0:{0:{score:1}}}},1:{1:{1:{1:{score:-1}}}}},1:{1:{0:{0:{0:{score:-1}}},1:{1:{1:{score:-1}}}}}}}}}};
+
+ this.data = new Array(this.width * this.height);
+ this.accumulator = new Array(this.data.length);
+
+ this.initWidthRandomValues();
+ }
+
+ doSimulationStep() {
+ this.accumulator.fill(0);
+
+ // unfortunately we hard-code size right now (It's just a prototype...)
+ const pw = 3;
+ const w = this.width;
+ const h = this.height;
+
+ for (let x = 0; x < w; x++)
+ for (let y = 0; y < h; y++) {
+ // find bad matches, find good matches
+ // get neighborhood
+ let data = [];
+ for (let dy = 0; dy < pw; dy++)
+ for (let dx = 0; dx < pw; dx++) {
+ let index = w * clamp(y + dy, h) + clamp(x + dx, w);
+ data.push(this.data[index]);
+ }
+ let score = this.getMatchScore(data);
+ if (score != 0) {
+ for (let dy = 0; dy < pw; dy++)
+ for (let dx = 0; dx < pw; dx++) {
+ let index = w * clamp(y + dy, h) + clamp(x + dx, w);
+ this.accumulator[index] += score;
+ }
+ }
+ }
+
+ for (let y = 0; y < h; y++)
+ for (let x = 0; x < w; x++) {
+ let index = y * w + x;
+ let mutate = this.accumulator[index] <= 0;
+ this.data[index] = mutate ? this.sampleNoise() : this.data[index];
+ }
+ }
+
+ doPostprocessingStep() {
+ for (let x = 0; x < this.width; x++)
+ for (let y = 0; y < this.height; y++) {
+ let sum = 0;
+ for(let d = 0; d < 4; d++)
+ sum += this.getAt(x+COS[d], y+SIN[d]);
+ if(sum < 2)
+ this.data[y*this.width+x] = 0;
+ }
+ }
+
+ floodFill(x, y, value) {
+ let queue = [x,y];
+ let initial = this.getAt(x, y);
+ for(let i = 0; i < 49; i++){
+ if(queue.length == 0) break;
+ y = queue.pop();
+ x = queue.pop();
+ if(this.getAt(x, y) == value) return;
+ this.setAt(x, y, value);
+
+ for(let d = 0; d < 4; d++) {
+ let adj = this.getAt(x+COS[d], y+SIN[d]);
+ if(adj == initial) {
+ queue.push(x+COS[d], y+SIN[d]);
+ }
+ }
+ }
+ }
+
+ getAt(x, y) {
+ return this.data[(clamp(y, this.height) * this.width) + clamp(x, this.width)];
+ }
+
+ setAt(x, y, value) {
+ this.data[(clamp(y, this.height) * this.width) + clamp(x, this.width)] = value;
+ }
+
+ getMatchScore(data) {
+ let node = this.patternTree;
+ for (let i = 0; i < data.length; i++) {
+ if (!node[data[i]]) {
+ return 0;
+ }
+ node = node[data[i]];
+ }
+
+ return node.score;
+ }
+
+ initWidthRandomValues() {
+ for (let y = 0; y < this.height; y++)
+ for (let x = 0; x < this.width; x++) {
+ // seed(Date.now()); // TODO implement seed?
+ let index = y * this.width + x;
+ this.data[index] = this.sampleNoise();
+ }
+ }
+
+ sampleNoise() {
+ return Math.random() < 0.5 ? 1 : 0;
+ }
+}
+
+export default class Generator {
+ constructor(size) {
+ this.size = size;
+ this.generate();
+ }
+
+ generate() {
+ let size = this.size;
+ let layers = [1, 1, 1, 1].map(f => new NoiseLayer(f * size, f * size));
+ // simulate noise and overlay
+ for (let layer of layers) {
+ for (let i = 0; i < 100; i++) {
+ layer.doSimulationStep();
+ }
+ for (let i = 0; i < 16; i++) {
+ layer.doPostprocessingStep();
+ }
+ }
+
+ // for(let i = 0; i < 1; i++) {
+ // layers[0].floodFill(~~(Math.random() * size), ~~(Math.random() * size), 1);
+ // }
+
+ for (let p of iterateQGrid(size, size)) {
+ let dh = Math.min(p.x, size-1-p.x);
+ let dv = Math.min(p.y, size-1-p.y);
+ let distanceToEdge = Math.min(dh, dv);
+ if(distanceToEdge == 0 || (dh == 1 && dv == 1))
+ layers[0].data[p.index] = 0;
+ else if(distanceToEdge == 1)
+ layers[0].data[p.index] = 1;
+ }
+
+ for (let i = 0; i < 16; i++) {
+ layers[0].doPostprocessingStep();
+ }
+
+ this.floorMap = layers[0].data;
+ this.heightmap = []
+
+ // calculate height map
+
+ for (let p of iterateQGrid(size, size)) {
+ let height = layers.map((sim) => (1 - sim.data[p.index])).reduce((a, b) => a + b);
+ height = layers[0].data[p.index] ? 0 : height;
+ this.heightmap[p.index] = height;
+ }
+ }
+
+ sampleHeightmap(x, y) {
+ // return 0;
+ // return Math.random() < 0.05? .5 : 0;
+ // if(x >= this.size || y >= this.size || x < 0 || y < 0) return 0;
+ const hm = this.heightmap[(clamp(y, this.size) * this.size) + clamp(x, this.size)];
+ return hm || this.sampleGroundHeightmap(x, y);
+ }
+
+ sampleGroundHeightmap(x, y) {
+ return 0 +
+ (Math.sin(y*3.141569*0.25) + Math.sin(x*3.141569*0.25) + 0) * .2;
+ // Math.abs(Math.sin(y*3.141569*1) + Math.sin(x*3.141569*1) + 0) * .1 +
+ // (Math.tan(x) + Math.tan(y)) *0.1 +
+ // Math.atan(x) + Math.atan(y)
+ ;
+ }
+
+ sampleFloorMap(x, y) {
+ return this.floorMap[(clamp(y, this.size) * this.size) + clamp(x, this.size)];
+ }
+}
+
+export function* iterateQGrid(w, h) {
+ for (let x = 0; x < w; x++)
+ for (let y = 0; y < h; y++) {
+ yield { x: x, y: y, index: y*w+x};
+ }
+}
+
+export function clamp(x, n) {
+ return Math.min(Math.max(~~x, 0), n-1);
+ // return ~~(((x%n)+n)%n);
+}
\ No newline at end of file
diff --git a/modules/keyHandler.js b/modules/keyHandler.js
new file mode 100644
index 00000000..c12bfd1a
--- /dev/null
+++ b/modules/keyHandler.js
@@ -0,0 +1,27 @@
+var keys = {};
+
+registerKey("left", [65, 37]);
+registerKey("right", [68, 39]);
+registerKey("up", [87, 38]);
+registerKey("down",[83, 40]);
+registerKey("exit",[27]);
+
+function registerKey(key, keyCodes) {
+ keys[key] = false;
+ document.addEventListener("keydown", (ev) => {
+ if(keyCodes.includes(ev.keyCode)) {
+ ev.preventDefault();
+ keys[key] = true;
+ }
+ });
+ document.addEventListener("keyup", (ev) => {
+ if(keyCodes.includes(ev.keyCode)) {
+ ev.preventDefault();
+ keys[key] = false;
+ }
+ });
+}
+
+export function keyIsDown(key) {
+ return !!keys[key];
+}
\ No newline at end of file
diff --git a/modules/renderer.js b/modules/renderer.js
new file mode 100644
index 00000000..2a61c1a1
--- /dev/null
+++ b/modules/renderer.js
@@ -0,0 +1,312 @@
+import { clamp } from "./generator.js";
+
+export class Texture {
+ constructor(w, h) {
+ this.data = new Array(w*h);
+ this.width = w;
+ this.height = h;
+ }
+
+ // TODO do this compile time / paste manually
+ static fromCanvas(canvas) {
+ // let canvas = document.createElement("canvas");
+ // let w = canvas.width = img.width;
+ // let h = canvas.height = img.height;
+ // ctx.drawImage(img, 0, 0);
+ let w = canvas.width;
+ let h = canvas.height;
+ let data = canvas.getContext("2d").getImageData(0, 0, w, h).data;
+ let texture = new Texture(w, h);
+ for(let i = 0; i < w * h; i++) {
+ texture.data[i] = [
+ data[i*4],
+ data[i*4+1],
+ data[i*4+2]
+ ];
+ }
+ return texture;
+ }
+
+ fill(val) {
+ this.data = this.data.fill(val);
+ }
+
+ sampleAt(x, y) {
+ return this.data[y*this.width + x];
+ }
+
+ setAt(x, y, value) {
+ this.data[y*this.width + x] = value;
+ }
+}
+
+const SUPER_TEXT_TIME = 120;
+
+export default class Renderer {
+ constructor(generator, canvas) {
+ this.generator = generator;
+ this.canvas = canvas;
+ this.x = 0;
+ this.y = 0.5;
+ this.z = 0.35;
+ this.angle = 0;
+ this.horizon = 0.1;
+
+ this.infoText = "";
+ this.infoTextStyle = "red";
+ this.timeLeft = 0;
+
+ this.pizzaPositions = [];
+ this.startPos = null;
+
+ this.carFrame = 1;
+ this.floorCanvas = null;
+ this.spritesheet = null;
+ }
+
+ setText(str, style) {
+ this.infoText = str.split("").join(" ");
+ this.infoTextStyle = style;
+ }
+
+ setSuperText(str) {
+ this.superText = str;// str.split("").join(" ");
+ this.superTextTimer = SUPER_TEXT_TIME;
+ }
+
+ async load() {
+ this.spritesheet = await this.loadImage("spritesheet");
+ this.imgCorolla = [
+ this.getImageFromSpritesheet(0, 0, 32, 16),
+ this.getImageFromSpritesheet(0, 16, 32, 16),
+ this.getImageFromSpritesheet(0, 0, 32, 16, true)
+ ];
+
+ this.indexedSprites = [];
+ this.indexedSprites[0] = this.getImageFromSpritesheet(32, 0, 32, 32);
+ this.indexedSprites[1] = this.getImageFromSpritesheet(80, 0, 32, 32);
+
+ // load tile data
+ this.tileTexture = Texture.fromCanvas(await this.getImageFromSpritesheet(64, 0, 16, 16));
+
+ this.renderFloorTexture();
+ this.renderMinimap();
+ }
+
+ async loadImage(name, format = "png") {
+ let img = document.createElement("img");
+ img.src = `images/${name}.${format}`;
+ await new Promise(resolve => { img.onload = resolve });
+ return img;
+ }
+
+ getImageFromSpritesheet(x, y, w, h, flipX = false) {
+ let canvas = document.createElement("canvas");
+ // x = w/2; y = h/2;
+ canvas.width = w;
+ canvas.height = h;
+ let ctx = canvas.getContext("2d");
+ if(flipX) ctx.scale(-1,1);
+ ctx.drawImage(this.spritesheet, -x - (flipX? w : 0), -y);
+ return canvas;
+ }
+
+ renderFloorTexture() {
+ const tileW = this.tileTexture.width;
+ const halfW = ~~(tileW/2);
+ const tileH = this.tileTexture.height;
+
+ let tex = new Texture(this.generator.size * tileW, this.generator.size * tileH);
+
+ const COS = [1, 0, -1, 0];
+ const SIN = [0, 1, 0, -1];
+ let transform = (x, y, r) => {
+ x -= (tileW-1)/2;
+ y -= (tileH-1)/2;
+ let bufX = COS[r] * x - SIN[r] * y;
+ y = SIN[r] * x + COS[r] * y;
+ x = bufX;
+ x += (tileW-1)/2;
+ y += (tileH-1)/2;
+ return {x: x, y: y};
+ }
+
+ for(let x = 0; x < this.generator.size; x++)
+ for(let y = 0; y < this.generator.size; y++) {
+ let baseID = this.generator.sampleFloorMap(x, y);
+ for(let dir = 0; dir < 4; dir++) {
+ const adjDir = (dir+2)%4;
+ const id = baseID * 2 + this.generator.sampleFloorMap(x + COS[adjDir], y + SIN[adjDir]);
+ // id = 0;
+ let fac = baseID == 0? (2+COS[dir])/5 : 1;
+
+ for(let locX = 0; locX < halfW; locX++) {
+ for(let locY = locX; locY < tileH-locX-1; locY++) {
+ const tp = transform(locX, locY, dir);
+ const sp = transform(locX, locY, id);
+ let sample = this.tileTexture.sampleAt(sp.x, sp.y);
+ if(sample)
+ tex.setAt(x*tileW + tp.x, y*tileH + tp.y, sample.map(v => v*fac));
+ }
+ }
+ }
+ }
+
+ this.floorCanvasData = tex;
+ }
+
+ renderMinimap() {
+ let canvas = document.createElement("canvas");
+ let ctx = canvas.getContext("2d");
+ const w = this.generator.size;
+ canvas.width = canvas.height = w;
+ let imgDat = ctx.getImageData(0, 0, w, w);
+ for(let x = 0; x < w; x++)
+ for(let y = 0; y < w; y++) {
+ const i = (y * w + x) * 4;
+ imgDat.data[i] =
+ imgDat.data[i+1] =
+ imgDat.data[i+2] = this.generator.sampleFloorMap(x, y)? 0 : 128;
+ imgDat.data[i+3] = 255;
+ }
+ ctx.putImageData(imgDat, 0, 0);
+ this.minimap = canvas;
+ }
+
+ sampleFloor(x, y) {
+ x = clamp(x * this.tileTexture.width, this.floorCanvasData.width);
+ y = clamp(y * this.tileTexture.height, this.floorCanvasData.height);
+ let sample = this.floorCanvasData.sampleAt(x,y);
+ // return sample.map(v => (1-lum) * 0x22 + lum * v);
+ return sample;
+ }
+
+ render() {
+ let t0 = Date.now();
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+ const d = 180;
+ const farPlane = 12;
+
+ let ctx = this.canvas.getContext("2d");
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, w, h);
+ let imgData = ctx.getImageData(0, 0, w, h);
+ const cosAngle = Math.cos(this.angle);
+ const sinAngle = Math.sin(this.angle);
+
+ let depthBuffer = new Array(w).fill(0);
+
+ // iterate from front to back
+ for (let screenZ = 0; screenZ <= d; screenZ++) {
+ // iterate from left to right
+ // const relDistance = Math.tan(screenZ / d * 1.57) * 0.125 * farPlane; // tangent bias
+ const relDistance = (screenZ / d)**2 * farPlane; // square bias
+ const rx = relDistance;
+ const lum = 1-relDistance/farPlane;
+ for (let screenX = 0; screenX < w; screenX++) {
+ const ry = relDistance * (screenX / w * 2 - 1);
+ const x = this.x + cosAngle * rx - sinAngle * ry;
+ const y = this.y + sinAngle * rx + cosAngle * ry;
+ const sample = this.generator.sampleHeightmap(x, y);
+
+ const relH = (sample - this.z + relDistance) / (relDistance * 2) + this.horizon;
+ const screenHeight = Math.min(~~(relH * h), h);
+
+ const col = this.sampleFloor(x, y);
+ const buf = depthBuffer[screenX];
+ for (let screenY = buf; screenY < screenHeight; screenY++) {
+ const worldY = (relH * h - 1 - screenY) * relDistance;
+ const i = ((h - screenY) * w + screenX) * 4;
+ const fac = ~~(1 + ~~(worldY) % 18 * 2 / 18);
+ for(let j = 0; j < 3; j++)
+ imgData.data[i+j] = (1-lum) * 0x22 + lum * (col[j] * fac);
+ imgData.data[i + 3] = 255;
+ }
+ if (screenHeight > buf) depthBuffer[screenX] = screenHeight;
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+ if (this.imgCorolla) {
+ let image = this.imgCorolla[this.carFrame];
+ ctx.drawImage(image, ~~(w / 2 - image.width / 2), ~~(h * 0.8 - image.height / 2), image.width, image.height);
+ }
+
+ // draw pizzas
+
+ for(let pizzaPos of this.pizzaPositions) {
+ // Crazy pizza math
+ const pizzaRY = (pizzaPos.y - (this.y + sinAngle * (pizzaPos.x - this.x) / cosAngle)) / (sinAngle * sinAngle / cosAngle + cosAngle);
+ const pizzaRX = (pizzaPos.x - this.x + sinAngle * pizzaRY) / cosAngle;
+ const pizzaSX = ((pizzaRY / pizzaRX) + 1) / 2 * w;
+ const pizzaSZ = Math.sqrt(pizzaRX / farPlane) * d;
+
+ const samplePizza = this.generator.sampleHeightmap(pizzaPos.x, pizzaPos.y);
+ const relPizzaH = (samplePizza - this.z + pizzaRX) / (pizzaRX * 2) + this.horizon;
+ const pizzaScale = 1 / pizzaRX;
+ const pizzaSY = ~~(relPizzaH * h);
+
+ if(pizzaSZ > 0) {
+ let img = this.indexedSprites[pizzaPos.spriteIndex];
+ let pizzaW = img.width * pizzaScale;
+ let pizzaH = img.height * pizzaScale;
+ ctx.drawImage(img, pizzaSX - pizzaW/2, h-pizzaSY - pizzaH/2, pizzaW, pizzaH);
+ }
+ }
+
+ // mini map
+ ctx.drawImage(this.minimap, 0, 0);
+ ctx.fillStyle = "red";
+ ctx.beginPath();
+ ctx.rect(clamp(this.x, this.generator.size), clamp(this.y, this.generator.size), 1, 1);
+ ctx.fill();
+ if(t0 % 250 < 125) {
+ ctx.beginPath();
+ ctx.fillStyle = "#f5cc7a";
+ for(let pizzaPos of this.pizzaPositions) {
+ ctx.rect(clamp(pizzaPos.x, this.generator.size), clamp(pizzaPos.y, this.generator.size), 1, 1);
+ }
+ ctx.fill();
+ }
+
+ // time text
+ ctx.font = "12px Arial";
+ ctx.textAlign = "right";
+ ctx.strokeStyle = "black";
+ ctx.fillStyle = "white";
+ let timeText = `${~~(this.timeLeft/60)}:${("0" + this.timeLeft % 60).substr(-2, 2)}`;
+ ctx.strokeText(timeText, w-2, 12);
+ ctx.fillText(timeText, w-2, 12);
+
+ // small normal text
+ ctx.font = "8px Arial";
+ ctx.textAlign = "center";
+ let a = (t0%100)/100*Math.PI;
+ let tx = w/2 + Math.cos(a);
+ let ty = h-5 + Math.sin(a);
+ ctx.strokeStyle = "black";
+ ctx.fillStyle = this.infoTextStyle;
+ ctx.strokeText(this.infoText, tx, ty);
+ ctx.fillText(this.infoText, tx, ty);
+
+ // super text
+ this.superTextTimer--;
+ if(this.superTextTimer > 0) {
+ let sc = Math.sin((this.superTextTimer/SUPER_TEXT_TIME)**8 * Math.PI);
+ let rot = (1 - this.superTextTimer/SUPER_TEXT_TIME) * .5;
+ ctx.translate(w/2, h/2);
+ ctx.rotate(rot);
+ ctx.scale(sc,sc);
+ ctx.font = "32px Arial";
+ ctx.strokeStyle = "2px solid red";
+ ctx.fillStyle = "white";
+ ctx.strokeText(this.superText, 0, 0, w*0.8);
+ ctx.fillText(this.superText, 0, 0, w*0.8);
+ }
+
+ let t = (Date.now() - t0);
+ // console.log(`render time: ${t}ms, ${1000/t}fps`);
+ document.querySelector("footer>p").innerHTML = `render time: ${t}ms, \t${~~(1000 / t)}fps x: ${this.x} y: ${this.y}`;
+ }
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..8354d352
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "js13k",
+ "version": "1.0.0",
+ "description": "my js13k 2019 entry",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/nilsgawlik/js13k2919.git"
+ },
+ "author": "Nils Gawlik",
+ "license": "",
+ "bugs": {
+ "url": "https://github.com/nilsgawlik/js13k2919/issues"
+ },
+ "homepage": "https://github.com/nilsgawlik/js13k2919#readme",
+ "devDependencies": {
+ "gulp": "^4.0.2",
+ "webpack-stream": "^5.2.1"
+ }
+}
diff --git a/promo_images/image_large.png b/promo_images/image_large.png
new file mode 100644
index 00000000..776e2487
Binary files /dev/null and b/promo_images/image_large.png differ
diff --git a/promo_images/image_thumbnail.png b/promo_images/image_thumbnail.png
new file mode 100644
index 00000000..96d39b56
Binary files /dev/null and b/promo_images/image_thumbnail.png differ
diff --git a/style.css b/style.css
new file mode 100644
index 00000000..e234044e
--- /dev/null
+++ b/style.css
@@ -0,0 +1,65 @@
+canvas {
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+}
+
+main {
+ max-width: 768px;
+ width: 100%;
+ height: auto;
+ padding: 0;
+ margin: 0 auto;
+ margin-top: 50px;
+}
+
+canvas.game {
+ display: block;
+ width: 100%;
+ height: auto;
+ background-color: #222;
+}
+
+.game-overlay {
+ position: absolute;
+ font-family: sans-serif;
+ text-align: center;
+}
+
+ h2 {
+ color: white;
+ font-size: 32px;
+ text-shadow: 4px 4px black;
+}
+
+.game-overlay p {
+ font-size: 24px;
+ color: #eee;
+ text-shadow: 2px 2px black;
+}
+
+.game-overlay em {
+ color: yellow;
+}
+
+.game-overlay button {
+ color: #eee;
+ background-color: #444;
+ text-shadow: 1px 1px black;
+ border: none;
+ padding: 15px 32px;
+ text-align: center;
+ text-decoration: none;
+ display: inline-block;
+ font-size: 16px;
+ box-shadow: 2px 2px black;
+}
+
+.game-overlay button:hover {
+ color: #eee;
+ background-color: #666;
+}
+
+body {
+ padding: 0;
+ background-color: #222;
+}
\ No newline at end of file