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 @@ + + + + + + + + + +
+ +
+ + \ 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