├── .editorconfig ├── .gitignore ├── README.md ├── dist ├── game.zip └── index.html ├── package.json ├── src ├── Game │ ├── Enemy.ts │ ├── Event.ts │ ├── Hero.ts │ ├── Map.ts │ ├── Menu.ts │ ├── Platform.ts │ ├── Scene.ts │ ├── Task.ts │ └── Token.ts ├── T3D │ ├── Camera.ts │ ├── Collider.ts │ ├── Item.ts │ ├── Mat4.ts │ ├── Mesh.ts │ ├── Shader.ts │ ├── Transform.ts │ ├── Vec3.ts │ └── index.ts ├── index.html ├── main.ts ├── sfx.ts ├── shader │ ├── tiny.frag │ └── tiny.vert └── style.scss ├── tsconfig.json ├── typings.d.ts └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.json] 16 | indent_size = 2 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /dist/*.css 3 | /dist/*.js 4 | /node_modules 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S P A C E C R A F T 2 | 3 | "Space, the final frontier" is your ultimate destination in SPACECRAFT. But first, your mission is to collect as many data tokens as possible from the planets and moons of the Solar System. As their gravity accelerates you have to stay on track as well as dodge space junk and asteroids, or zap them with your booster, at least until your probe goes OFFLINE. 4 | 5 | [PLAY](https://tricsi.github.io/spacecraft/dist/) -------------------------------------------------------------------------------- /dist/game.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tricsi/spacecraft/bb15d3f66a29005985b87f52612ef22bc849a8af/dist/game.zip -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | SPACECRAFT

Distance travelled
Tokens collected
Big tokens collected
Asteroids destroyed
Places visited
Mission completed
TOTAL
OK

SPACECRAFT

JUMP - UP arrow key
SHRINK - DOWN arrow key
MOVE - LEFT / RIGHT arrow keys
BOOST - SPACE key

JUMP - Swipe UP
SHRINK - Swipe DOWN
MOVE - Swipe LEFT / RIGHT
BOOST - TAP

BIG TOKENS help you collect small ones.
Use SHRINK to go through SPACE JUNK.
Use BOOST to destroy ASTEROIDS.

START
-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spacecraft", 3 | "version": "1.0.0", 4 | "description": "2018 js13kgames.com entry.", 5 | "repository": "https://github.com/tricsi/spacecraft", 6 | "author": "@CsabaCsecskedi", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "webpack --mode=production", 10 | "start": "webpack-dev-server --open" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^11.12.1", 14 | "advzip-bin": "^1.1.0", 15 | "clean-webpack-plugin": "^2.0.1", 16 | "css-loader": "^3.1.0", 17 | "file-loader": "^3.0.1", 18 | "glsl-minify-loader": "^1.0.0", 19 | "html-webpack-inline-source-plugin": "0.0.10", 20 | "html-webpack-plugin": "^3.2.0", 21 | "mini-css-extract-plugin": "^0.8.0", 22 | "node-sass": "^4.12.0", 23 | "optimize-css-assets-webpack-plugin": "^5.0.3", 24 | "sass-loader": "^7.1.0", 25 | "script-loader": "^0.7.2", 26 | "terser-webpack-plugin": "^1.3.0", 27 | "ts-loader": "^5.3.3", 28 | "typescript": "^3.4.1", 29 | "webpack": "^4.29.6", 30 | "webpack-cli": "^3.3.0", 31 | "webpack-dev-server": "^3.2.1", 32 | "webpack-merge": "^4.2.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Game/Enemy.ts: -------------------------------------------------------------------------------- 1 | import { Hero } from "./Hero"; 2 | import { Event } from "./Event"; 3 | import { Item } from "../T3D/index"; 4 | 5 | export class Enemy extends Item { 6 | 7 | explode: number; 8 | 9 | init(active: boolean) { 10 | this.active = active; 11 | this.stroke = 0; 12 | this.explode = 0; 13 | this.transform.rotate.set(0, 0, 0); 14 | this.transform.translate.set(0, 1, 0); 15 | } 16 | 17 | update(speed: number, end:boolean) { 18 | if (!this.active) { 19 | return; 20 | } 21 | this.stroke += (this.explode - this.stroke) / 25; 22 | if (this.stroke) { 23 | return; 24 | } 25 | let pos = this.transform.translate, 26 | rotate = this.transform.rotate; 27 | pos.z = end ? 0 : pos.z + speed / 2; 28 | rotate.z = (rotate.z + 5) % 360; 29 | rotate.x = (rotate.x + 3) % 360; 30 | } 31 | 32 | intersect(hero: Hero) { 33 | if (this.active && !this.explode && !hero.explode && this.collider.intersect(hero.collider)) { 34 | if (hero.speedTime) { 35 | this.explode = 7; 36 | Event.trigger('hit', hero); 37 | return; 38 | } 39 | hero.explode = 7; 40 | Event.trigger('exp', hero); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Game/Event.ts: -------------------------------------------------------------------------------- 1 | export class Event { 2 | 3 | private static listener: {[event: string]: {(event?:string, params?: any): void}[]} = { all: [] }; 4 | 5 | static on(event:string, listener: {(event?:string, params?: any): void}): void { 6 | const events = event.match(/[a-zA-Z]+/g); 7 | if (!events) { 8 | return; 9 | } 10 | events.forEach(event => { 11 | if (!(event in Event.listener)) { 12 | Event.listener[event] = []; 13 | } 14 | Event.listener[event].push(listener); 15 | }); 16 | } 17 | 18 | static trigger(event: string, params?: any): void { 19 | Event.listener['all'].forEach(listener => { 20 | listener(event, params); 21 | }); 22 | if (event in Event.listener) { 23 | Event.listener[event].forEach(listener => { 24 | listener(event, params); 25 | }); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Game/Hero.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./Event"; 2 | import { COLOR } from "../main"; 3 | import { Item, Vec3, Sphere } from "../T3D/index"; 4 | 5 | export class Hero extends Item { 6 | 7 | x: number; 8 | rad: number; 9 | acc: number; 10 | speed: Vec3; 11 | speedTime: number; 12 | scale: number; 13 | scaleTime: number; 14 | magnet: Vec3; 15 | magnetTime: number; 16 | distance: number; 17 | tokenCollider: Sphere; 18 | collide: Vec3; 19 | explode: number; 20 | 21 | init(reset: boolean = true) { 22 | const transform = this.transform; 23 | transform.translate.set(0, 0, 0); 24 | transform.rotate.set(0, 0, 90); 25 | transform.scale.set(1, 1, 1); 26 | this.color = COLOR.WHITE; 27 | this.active = true; 28 | this.transform = transform; 29 | this.collider = new Sphere(transform); 30 | this.tokenCollider = new Sphere(transform); 31 | this.x = 0; 32 | this.rad = .4; 33 | this.acc = -.015; 34 | this.speed = new Vec3(0, 0, .1); 35 | this.speedTime = 0; 36 | this.scale = .8; 37 | this.scaleTime = 0; 38 | this.magnet = new Vec3(5, 5, 5); 39 | this.magnetTime = 0; 40 | this.explode = 0; 41 | this.stroke = 0; 42 | if (reset) { 43 | this.distance = 0; 44 | } 45 | } 46 | 47 | left() { 48 | if (this.x >= 0) { 49 | this.x--; 50 | Event.trigger('move', this); 51 | } 52 | } 53 | 54 | right() { 55 | if (this.x <= 0) { 56 | this.x++; 57 | Event.trigger('move', this); 58 | } 59 | } 60 | 61 | jump() { 62 | if (this.collide) { 63 | this.acc = .03; 64 | Event.trigger('jump', this); 65 | } 66 | } 67 | 68 | boost() { 69 | this.speedTime = 75; 70 | Event.trigger('move', this); 71 | } 72 | 73 | magnetize() { 74 | this.magnetTime = 450; 75 | Event.trigger('power', this); 76 | } 77 | 78 | dash() { 79 | this.scaleTime = 40; 80 | Event.trigger('move', this); 81 | } 82 | 83 | coin() { 84 | Event.trigger('coin', this); 85 | } 86 | 87 | cancel() { 88 | this.x = Math.round(this.transform.translate.x); 89 | } 90 | 91 | update() { 92 | let pos = this.transform.translate, 93 | scale = this.scale, 94 | rotate = this.transform.rotate, 95 | speed = (this.speedTime ? .13 : .08) + Math.min(this.distance / 15000, .04); 96 | this.speed.z += ((this.active ? speed : 0) - this.speed.z) / 20; 97 | this.speedTime -= this.speedTime > 0 ? 1 : 0; 98 | this.color = this.magnetTime > 100 || this.magnetTime % 20 > 10 ? COLOR.PINK : COLOR.WHITE; 99 | this.scale += ((this.scaleTime ? .5 : .7) - this.scale) / 5; 100 | this.scaleTime -= this.scaleTime > 0 ? 1 : 0; 101 | this.magnetTime -= this.magnetTime > 0 ? 1 : 0; 102 | this.tokenCollider.scale = this.magnetTime ? this.magnet : this.transform.scale; 103 | this.stroke += (this.explode - this.stroke) / 25; 104 | this.active = pos.y > -10 && this.stroke < 6; 105 | if (!this.active || this.stroke) { 106 | return; 107 | } 108 | this.acc -= this.acc > -.012 ? .003 : 0; 109 | rotate.z = 90 + (pos.x - this.x) * 25; 110 | rotate.y = (rotate.y + this.speed.z * 100) % 360; 111 | this.speed.y += this.acc; 112 | if (this.speed.y < -.25) { 113 | this.speed.y = -.25; 114 | } 115 | pos.x += (this.x - pos.x) / 7; 116 | pos.y += this.speed.y; 117 | pos.z -= pos.z / 30; 118 | this.transform.scale.set(scale, scale, scale); 119 | } 120 | 121 | preview() { 122 | let rotate = this.transform.rotate; 123 | rotate.y = (rotate.y + 1) % 360; 124 | rotate.z = (rotate.z + .7) % 360; 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/Game/Map.ts: -------------------------------------------------------------------------------- 1 | import { Rand } from "../main"; 2 | 3 | export class Map { 4 | 5 | mirror: boolean; 6 | config: string[]; 7 | count: number; 8 | data: string[]; 9 | row: number[]; 10 | length: number; 11 | steps: number; 12 | step: number; 13 | min: number; 14 | 15 | constructor(config: string, lenght: number = 7, steps: number = 150) { 16 | this.config = config.split('|'); 17 | this.length = lenght; 18 | this.steps = steps; 19 | } 20 | 21 | init() { 22 | this.row = [1,1,1]; 23 | this.count = 10; 24 | this.data = []; 25 | this.step = 0; 26 | this.min = 0; 27 | this.update(); 28 | } 29 | 30 | max() { 31 | let max = this.min + this.length, 32 | length = this.config.length; 33 | return max < length ? max : length - 1; 34 | } 35 | 36 | update(): boolean { 37 | let next = false; 38 | if (++this.step > this.steps) { 39 | next = true; 40 | this.step = 0; 41 | if (this.min + this.length < this.config.length - 1) { 42 | this.min++; 43 | } 44 | } 45 | if (--this.count > 0) { 46 | return next; 47 | } 48 | if (!this.data.length) { 49 | this.mirror = Rand.get() > .5; 50 | let index = Rand.get(this.max(), this.min, true); 51 | this.data = this.config[index].match(/.{1,4}/g); 52 | } 53 | this.row = this.data.shift().split('').map(c => parseInt(c, 36)); 54 | this.count = this.row.shift(); 55 | if (this.mirror) { 56 | this.row.reverse(); 57 | } 58 | return next; 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Game/Menu.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./Event"; 2 | import { $, on } from "../main"; 3 | import { Hero } from "./Hero"; 4 | import { Task } from "./Task"; 5 | import SFX from "../sfx"; 6 | 7 | const STORE = 'offliner_hi'; 8 | 9 | export class Menu { 10 | 11 | body: Element; 12 | btn: Element; 13 | shop: boolean; 14 | info: HTMLCollectionOf; 15 | active: boolean; 16 | storage: any; 17 | selected: number; 18 | heroes: any[]; 19 | scores: HTMLCollectionOf; 20 | tasklist: HTMLCollectionOf; 21 | tasks: Task[]; 22 | stats: any; 23 | sfxBtn: Element; 24 | volume: number; 25 | 26 | constructor() { 27 | let data = JSON.parse(window.localStorage.getItem(STORE)); 28 | this.body = $('body'); 29 | this.btn = $('#play'); 30 | this.info = document.getElementsByTagName('H3'); 31 | this.shop = true; 32 | this.active = true; 33 | this.storage = data && typeof data === 'object' && 'shop' in data ? data : { 34 | score: 0, 35 | token: 0, 36 | level: 0, 37 | shop: [0] 38 | }; 39 | this.selected = 0; 40 | this.heroes = [ 41 | {name: 'SPUTNIK', price: 0}, 42 | {name: 'VOYAGER', price: 500}, 43 | {name: 'PIONEER', price: 1000}, 44 | {name: 'CASSINI', price: 2500} 45 | ]; 46 | this.tasklist = document.getElementsByTagName('H4'); 47 | this.scores = document.getElementsByTagName('TD'); 48 | this.stats = {}; 49 | this.sfxBtn = $('#sfx'); 50 | this.volume = .3; 51 | this.hero(); 52 | this.bind(); 53 | this.init(); 54 | } 55 | 56 | level() { 57 | return this.storage.level + 1; 58 | } 59 | 60 | init() { 61 | let level = this.level(), 62 | tasks = [], 63 | target = Math.ceil(level / 3); 64 | switch (level % 3) { 65 | case 1: 66 | tasks.push(new Task('coin', target * 75)); 67 | break; 68 | case 2: 69 | tasks.push(new Task('power', target, target % 2 == 0)); 70 | break; 71 | default: 72 | tasks.push(new Task('coin', target * 50, true)); 73 | break; 74 | } 75 | target = Math.ceil(level / 4); 76 | switch (level % 4) { 77 | case 1: 78 | tasks.push(target < 8 79 | ? new Task('planet', target) 80 | : new Task('hit', target, true) 81 | ); 82 | break; 83 | case 2: 84 | tasks.push(target % 2 == 1 85 | ? new Task('fence', 5 * target) 86 | : new Task('fence', 3 * target, true) 87 | ); 88 | break; 89 | case 3: 90 | tasks.push(target % 2 == 1 91 | ? new Task('enemy', 3 * target) 92 | : new Task('enemy', 2 * target, true) 93 | ); 94 | break; 95 | default: 96 | tasks.push(new Task('hit', target)); 97 | break; 98 | } 99 | this.tasks = tasks; 100 | } 101 | 102 | bind() { 103 | on($('#ok'), 'click', () => { 104 | Event.trigger('end'); 105 | }); 106 | on(this.btn, 'click', () => { 107 | this.play(); 108 | }); 109 | on($('#prev'), 'click', () => { 110 | this.prev(); 111 | }); 112 | on($('#next'), 'click', () => { 113 | this.next(); 114 | }); 115 | on($('#fs'), 'click', () => { 116 | //@ts-ignore 117 | if (!document.webkitFullscreenElement) document.documentElement.webkitRequestFullscreen(); 118 | //@ts-ignore 119 | else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); 120 | }); 121 | on(this.sfxBtn, 'click', () => { 122 | let btn = this.sfxBtn, 123 | music = SFX.mixer('music'), 124 | sound = SFX.mixer('master'), 125 | time = sound.context.currentTime; 126 | try { 127 | switch(btn.className) { 128 | case 'no': 129 | this.volume = .3; 130 | music.gain.setValueAtTime(this.volume, time); 131 | sound.gain.setValueAtTime(1, time); 132 | btn.className = ''; 133 | break; 134 | case 'sfx': 135 | sound.gain.setValueAtTime(0, time); 136 | btn.className = 'no'; 137 | break; 138 | default: 139 | this.volume = 0; 140 | music.gain.setValueAtTime(this.volume, time); 141 | btn.className = 'sfx'; 142 | } 143 | } catch (ex) {} 144 | }); 145 | Event.on('all', (event) => { 146 | if (event in this.stats) { 147 | this.stats[event] += 1; 148 | } else { 149 | this.stats[event] = 1; 150 | } 151 | this.tasks.forEach(task => { 152 | task.on(event); 153 | }); 154 | }); 155 | } 156 | 157 | input(key: number): void { 158 | if (!this.active) { 159 | return; 160 | } 161 | switch (key) { 162 | case 32: 163 | if (this.shop) { 164 | this.play() 165 | } else { 166 | Event.trigger('end'); 167 | } 168 | break; 169 | case 37: 170 | this.prev(); 171 | break; 172 | case 39: 173 | this.next(); 174 | break; 175 | } 176 | } 177 | 178 | play() { 179 | if (this.btn.textContent == 'PLAY') { 180 | this.stats = {}; 181 | Event.trigger('start'); 182 | } else if (this.btn.className == '') { 183 | this.storage.token -= this.heroes[this.selected].price; 184 | this.storage.shop.push(this.selected); 185 | this.store(); 186 | this.hero(); 187 | } 188 | } 189 | 190 | hero() { 191 | let token = this.storage.token, 192 | data = this.heroes[this.selected], 193 | buy = this.storage.shop.indexOf(this.selected) < 0, 194 | can = token >= data.price; 195 | this.info.item(0).textContent = data.name; 196 | this.info.item(1).textContent = buy ? `₮ ${data.price} / ${token}` : ''; 197 | this.btn.textContent = buy ? 'BUY' : 'PLAY'; 198 | this.btn.className = !buy || can ? '' : 'disabled'; 199 | } 200 | 201 | prev() { 202 | if (--this.selected < 0) { 203 | this.selected = this.heroes.length -1; 204 | } 205 | this.hero(); 206 | } 207 | 208 | next() { 209 | if (++this.selected >= this.heroes.length) { 210 | this.selected = 0; 211 | } 212 | this.hero(); 213 | } 214 | 215 | store() { 216 | window.localStorage.setItem(STORE, JSON.stringify(this.storage)); 217 | } 218 | 219 | mission(result: boolean = false) { 220 | let complete = true; 221 | this.tasks.forEach((task, i) => { 222 | if (!result) { 223 | task.init(); 224 | } 225 | let item = this.tasklist.item(i + 1); 226 | item.textContent = task.toString(); 227 | item.className = task.done ? 'done' : ''; 228 | complete = complete && task.done; 229 | }); 230 | if (complete) { 231 | this.storage.level++; 232 | this.store(); 233 | this.init(); 234 | } 235 | return complete; 236 | } 237 | 238 | score(hero: Hero) { 239 | let high = this.storage.score || 0, 240 | element = this.tasklist.item(0), 241 | scores = this.scores, 242 | hit = this.stats.hit || 0, 243 | places = (this.stats.planet || 0) + 1, 244 | power = this.stats.power || 0, 245 | tokens = this.stats.coin || 0, 246 | total = Math.round(hero.distance), 247 | mission = this.mission(true) ? 1 : 0; 248 | scores.item(0).textContent = total + ''; 249 | scores.item(1).textContent = '₮ ' + tokens + ' x 10'; 250 | scores.item(2).textContent = power + ' x 25'; 251 | scores.item(3).textContent = hit + ' x 50'; 252 | scores.item(4).textContent = places + ' x 100'; 253 | scores.item(5).textContent = mission + ' x 500'; 254 | total += (mission * 500) + (places * 100) + (hit * 50) + (power * 25) + (tokens * 10); 255 | scores.item(6).textContent = total + ''; 256 | if (high < total) { 257 | element.textContent = 'NEW HIGH SCORE'; 258 | this.storage.score = total; 259 | } else { 260 | element.textContent = 'SCORE'; 261 | } 262 | this.storage.token += tokens; 263 | this.store(); 264 | this.active = true; 265 | this.body.className = 'end'; 266 | } 267 | 268 | show() { 269 | this.shop = true; 270 | this.body.className = ''; 271 | } 272 | 273 | hide() { 274 | this.shop = false; 275 | this.active = false; 276 | this.mission(); 277 | this.tasklist.item(0).textContent = 'MISSION ' + this.level(); 278 | this.scores.item(4).textContent = 279 | this.scores.item(5).textContent = ''; 280 | this.body.className = 'play'; 281 | } 282 | 283 | } 284 | -------------------------------------------------------------------------------- /src/Game/Platform.ts: -------------------------------------------------------------------------------- 1 | import { Enemy } from "./Enemy"; 2 | import { Hero } from "./Hero"; 3 | import { Token } from "./Token"; 4 | import { Item } from "../T3D/index"; 5 | 6 | export class Platform extends Item { 7 | 8 | block: Item; 9 | token: Token; 10 | fence: Item; 11 | enemy: Enemy; 12 | 13 | update(speed: number): boolean { 14 | let pos = this.transform.translate; 15 | pos.z += speed; 16 | let end = pos.z > 2; 17 | if (end) { 18 | pos.z -= 11; 19 | } 20 | let scale = 1; 21 | if (pos.z < -8) { 22 | scale = pos.z + 9; 23 | } else if (pos.z > 1) { 24 | scale = 2 - pos.z; 25 | } 26 | this.transform.scale.set(scale, scale, scale); 27 | this.token.update(); 28 | this.enemy.update(speed, end); 29 | return end; 30 | } 31 | 32 | intersect(hero: Hero, side: boolean = false) { 33 | if (!hero.active || hero.stroke) { 34 | return; 35 | } 36 | let fence = this.fence, 37 | collide; 38 | this.token.intersect(hero); 39 | if (fence.active) { 40 | collide = fence.collider.intersect(hero.collider); 41 | if (collide) { 42 | if (side && collide.x) hero.cancel(); 43 | hero.transform.translate.add(collide); 44 | hero.speed.y += collide.y; 45 | } 46 | } 47 | if (!this.block.active) { 48 | return; 49 | } 50 | collide = this.block.collider.intersect(hero.collider); 51 | if (collide) { 52 | if (side && collide.x) hero.cancel(); 53 | hero.transform.translate.add(collide); 54 | hero.speed.y += collide.y; 55 | } 56 | return collide; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Game/Scene.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./Event"; 2 | import { Hero } from "./Hero"; 3 | import { Map } from "./Map"; 4 | import { Platform } from "./Platform"; 5 | import { Item } from "../T3D/index"; 6 | 7 | export class Scene extends Item { 8 | 9 | hero: Hero; 10 | map: Map; // platform bit map 11 | row: number; // active row 12 | index: number; // active platform 13 | planet: number; // active planet 14 | planets: HTMLCollectionOf; 15 | platforms: Platform[]; 16 | 17 | constructor(hero: Hero, factory: () => Platform, map: Map) { 18 | super(); 19 | this.map = map; 20 | this.hero = hero; 21 | this.add(this.hero); 22 | this.planets = document.getElementsByTagName('LI'); 23 | this.platforms = []; 24 | for (let i = 0; i < 33; i++) { 25 | let platform = factory(); 26 | this.platforms.push(platform); 27 | this.add(platform); 28 | } 29 | this.init(); 30 | } 31 | 32 | init() { 33 | this.row = 9; 34 | this.hero.init(); 35 | this.map.init(); 36 | let i = 0; 37 | for (let z = -9; z < 2; z++) { 38 | for (let x = -1; x <= 1; x++) { 39 | let platform = this.platforms[i++]; 40 | platform.transform.translate.set(x, -1, z); 41 | platform.enemy.active = 42 | platform.fence.active = 43 | platform.token.active = false; 44 | platform.block.active = true; 45 | } 46 | } 47 | this.planet = this.planets.length - 1; 48 | for (i = 0; i < this.planets.length; i++) { 49 | this.planets.item(i).className = ''; 50 | } 51 | } 52 | 53 | next() { 54 | if (this.planet > 0) { 55 | this.planets.item(this.planet--).className = 'hide'; 56 | Event.trigger('planet', this.planet); 57 | } 58 | } 59 | 60 | ended() { 61 | return Math.abs(this.hero.speed.z) < .01; 62 | } 63 | 64 | input(key: number): void { 65 | const hero = this.hero; 66 | switch (key) { 67 | case 37: 68 | hero.left(); 69 | break; 70 | case 39: 71 | hero.right(); 72 | break; 73 | case 38: 74 | hero.jump(); 75 | break; 76 | case 40: 77 | hero.dash(); 78 | break; 79 | case 32: 80 | hero.boost(); 81 | } 82 | } 83 | 84 | updateRow(speed:number) { 85 | this.row -= speed; 86 | if (this.row <= -.5) { 87 | this.row += 11; 88 | } 89 | this.index = Math.round(this.row) * 3 + Math.round(this.hero.transform.translate.x) + 1; 90 | } 91 | 92 | getIndex(add: number = 0): number { 93 | let length = this.platforms.length, 94 | index = this.index + add; 95 | if (index < 0) { 96 | return index + length; 97 | } 98 | if (index >= length) { 99 | return index - length; 100 | } 101 | return index; 102 | } 103 | 104 | update() { 105 | this.hero.update(); 106 | let rotate = false, 107 | hero = this.hero, 108 | speed = hero.speed.z, 109 | fence = 0, 110 | enemy = 0; 111 | this.platforms.forEach((platform, i) => { 112 | if (platform.update(speed)) { 113 | fence += platform.fence.active && hero.transform.translate.y > -1 ? 1 : 0; 114 | enemy += platform.enemy.active && !platform.enemy.stroke && !hero.stroke ? 1 : 0; 115 | let cfg = this.map.row[i % 3], 116 | obj = cfg >> 2; 117 | platform.block.active = (cfg & 1) > 0; 118 | platform.transform.translate.y = (cfg & 2) > 0 ? 0 : -1; 119 | platform.token.init(obj == 1 || obj == 4); 120 | platform.fence.active = obj == 2; 121 | platform.enemy.init(obj == 3); 122 | platform.token.transform.rotate.y = 45; 123 | rotate = true; 124 | } 125 | platform.enemy.intersect(hero); 126 | }); 127 | if (rotate && this.map.update()) { 128 | this.next(); 129 | } 130 | this.updateRow(speed); 131 | hero.collide = this.platforms[this.getIndex()].intersect(hero); 132 | [-3, 3, -1, 1, -2, 2, -4, 4].forEach(add => { 133 | let index = this.getIndex(add), 134 | platform = this.platforms[index]; 135 | platform.intersect(hero, add == 1 || add == -1); 136 | }); 137 | hero.distance += speed; 138 | if (fence > 0) { 139 | Event.trigger('fence', fence); 140 | } 141 | if (enemy > 0) { 142 | Event.trigger('enemy', enemy); 143 | } 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/Game/Task.ts: -------------------------------------------------------------------------------- 1 | const LABEL: {[key: string]: string} = { 2 | coin: 'Collect $ token', 3 | power: 'Collect $ big token', 4 | planet: 'Travel to $', 5 | fence: 'Dodge junks $ time', 6 | enemy: 'Dodge asteroids $ time', 7 | hit: 'Destroy $ asteroid', 8 | }; 9 | 10 | const PLANETS = ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', 'Space']; 11 | 12 | export class Task { 13 | 14 | event: string; 15 | 16 | target: number; 17 | 18 | count: number; 19 | 20 | run: boolean; 21 | 22 | done: boolean; 23 | 24 | constructor(event: string, target: number, run: boolean = false) { 25 | this.event = event; 26 | this.target = target; 27 | this.count = 0; 28 | this.run = run || event == 'planet'; 29 | this.done = false; 30 | } 31 | 32 | init() { 33 | if (!this.done && this.run) { 34 | this.count = 0; 35 | } 36 | } 37 | 38 | on(event: string) { 39 | if (!this.done && this.event == event) { 40 | this.done = this.target <= ++this.count; 41 | } 42 | } 43 | 44 | toString() { 45 | let event = this.event, 46 | text = LABEL[event], 47 | param = this.target.toString(); 48 | if (event == 'planet') { 49 | param = PLANETS[this.target - 1]; 50 | } else { 51 | if (this.target > 1) { 52 | text += 's'; 53 | } 54 | if (this.run) { 55 | text += ' on a mission' 56 | } 57 | if (!this.done && this.count) { 58 | param += ' / ' + this.count; 59 | } 60 | } 61 | return text.replace('$', param); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Game/Token.ts: -------------------------------------------------------------------------------- 1 | import { Rand } from "../main"; 2 | import { Hero } from "./Hero"; 3 | import { Item } from "../T3D/index"; 4 | 5 | const yellow = [1, 1, .3, 30]; 6 | const purple = [1, .3, 1, 30]; 7 | 8 | export class Token extends Item { 9 | 10 | big: boolean = false; 11 | speed: number; 12 | 13 | init(active: boolean) { 14 | this.active = active; 15 | this.transform.translate.set(0, 1, 0); 16 | this.big = !Rand.get(50, 0, true); 17 | this.speed = .01; 18 | } 19 | 20 | score() { 21 | return this.big ? 5 : 1; 22 | } 23 | 24 | update() { 25 | let rotate = this.transform.rotate, 26 | scale = this.transform.scale; 27 | rotate.y = (rotate.y + 1.5) % 360; 28 | if (this.big) { 29 | scale.set(.7, .15, .7); 30 | this.color = purple; 31 | } else { 32 | scale.set(.5, .1, .5); 33 | this.color = yellow; 34 | } 35 | } 36 | 37 | intersect(hero: Hero) { 38 | let collider = this.big ? hero.collider : hero.tokenCollider; 39 | if (this.active && this.collider.intersect(collider)) { 40 | let pos = this.collider.getTranslate(); 41 | if (pos.distance(hero.transform.translate) < .5) { 42 | this.active = false; 43 | if (this.big) { 44 | hero.magnetize(); 45 | } else { 46 | hero.coin(); 47 | } 48 | return; 49 | } 50 | this.speed += this.speed; 51 | this.transform.translate.add(hero.transform.translate.clone().sub(pos).scale(this.speed)); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/T3D/Camera.ts: -------------------------------------------------------------------------------- 1 | import { Vec3 } from "./Vec3"; 2 | import { Mat4 } from "./Mat4"; 3 | import { Transform } from "./Transform"; 4 | 5 | export class Camera { 6 | 7 | rotate: Vec3 = new Vec3(); 8 | position: Vec3 = new Vec3(); 9 | 10 | constructor( 11 | public aspect: number = 1, 12 | public fov: number = 45, 13 | public near: number = .1, 14 | public far: number = 100 15 | ) { 16 | this.fov = fov; 17 | this.aspect = aspect; 18 | this.near = near; 19 | this.far = far; 20 | } 21 | 22 | transform(transform: Transform): Mat4 { 23 | return transform.matrix() 24 | .rotate(this.rotate.clone().invert()) 25 | .translate(this.position.clone().invert()); 26 | } 27 | 28 | perspective(): Mat4 { 29 | const near = this.near; 30 | const far = this.far; 31 | const f: number = Math.tan(Math.PI * 0.5 - 0.5 * this.fov); 32 | const rangeInv: number = 1.0 / (near - far); 33 | return new Mat4().multiply([ 34 | f / this.aspect, 0, 0, 0, 35 | 0, f, 0, 0, 36 | 0, 0, (near + far) * rangeInv, -1, 37 | 0, 0, near * far * rangeInv * 2, 0 38 | ]); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/T3D/Collider.ts: -------------------------------------------------------------------------------- 1 | import { Vec3 } from "./Vec3"; 2 | import { Transform } from "./Transform"; 3 | 4 | export abstract class Collider { 5 | 6 | constructor( 7 | public transform: Transform, 8 | public scale: Vec3 = transform.scale 9 | ) { 10 | } 11 | 12 | getTranslate(): Vec3 { 13 | let translate = this.transform.translate.clone(), 14 | parent = this.transform.parent; 15 | while (parent) { 16 | translate.scale(parent.scale).add(parent.translate); 17 | parent = parent.parent; 18 | } 19 | return translate; 20 | } 21 | 22 | getScale(): Vec3 { 23 | let scale = this.scale.clone().scale(.5), 24 | parent = this.transform.parent; 25 | while (parent) { 26 | scale.scale(parent.scale); 27 | parent = parent.parent; 28 | } 29 | return scale; 30 | } 31 | 32 | abstract intersect(other: Sphere): Vec3; 33 | 34 | } 35 | 36 | export class Sphere extends Collider { 37 | 38 | intersect(other: Sphere): Vec3 { 39 | let collide = null, 40 | translate = this.getTranslate(), 41 | otherTranslate = other.getTranslate(), 42 | distance = translate.distance(otherTranslate), 43 | minDistance = this.getScale().max() + other.getScale().max(); 44 | if (distance < minDistance) { 45 | collide = otherTranslate.sub(translate).normalize().scale(minDistance - distance); 46 | } 47 | return collide; 48 | } 49 | 50 | } 51 | 52 | export class Box extends Collider { 53 | 54 | intersect(other: Sphere) { 55 | let pos = this.getTranslate(), 56 | scale = this.getScale(), 57 | otherPos = other.getTranslate(), 58 | otherScale = other.getScale().max(), 59 | closest = new Vec3( 60 | Math.max(pos.x - scale.x, Math.min(otherPos.x, pos.x + scale.x)), 61 | Math.max(pos.y - scale.y, Math.min(otherPos.y, pos.y + scale.y)), 62 | Math.max(pos.z - scale.z, Math.min(otherPos.z, pos.z + scale.z)) 63 | ), 64 | distance = closest.distance(otherPos), 65 | collide = null; 66 | if (distance < otherScale) { 67 | collide = otherPos.sub(closest).normalize().scale(otherScale - distance); 68 | } 69 | return collide; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/T3D/Item.ts: -------------------------------------------------------------------------------- 1 | import { Mesh } from "./Mesh"; 2 | import { Collider } from "./Collider"; 3 | import { Transform } from "./Transform"; 4 | 5 | export class Item { 6 | 7 | transform: Transform; 8 | collider: Collider; 9 | childs: Array = []; 10 | active: boolean = true; 11 | stroke: number = 0; 12 | 13 | constructor( 14 | public mesh?: Mesh, 15 | public color?: Array, 16 | transform?: Array 17 | ) { 18 | this.transform = new Transform(transform); 19 | } 20 | 21 | add(child: Item) { 22 | this.childs.push(child); 23 | child.transform.parent = this.transform; 24 | return this; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/T3D/Mat4.ts: -------------------------------------------------------------------------------- 1 | import { Vec3 } from "./Vec3"; 2 | 3 | export class Mat4 { 4 | 5 | constructor(public data: Array = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]) { 6 | } 7 | 8 | clone(): Mat4 { 9 | return new Mat4(this.data); 10 | } 11 | 12 | multiply(b: Array): Mat4 { 13 | const a: Array = this.data, 14 | a00: number = a[0 * 4 + 0], 15 | a01: number = a[0 * 4 + 1], 16 | a02: number = a[0 * 4 + 2], 17 | a03: number = a[0 * 4 + 3], 18 | a10: number = a[1 * 4 + 0], 19 | a11: number = a[1 * 4 + 1], 20 | a12: number = a[1 * 4 + 2], 21 | a13: number = a[1 * 4 + 3], 22 | a20: number = a[2 * 4 + 0], 23 | a21: number = a[2 * 4 + 1], 24 | a22: number = a[2 * 4 + 2], 25 | a23: number = a[2 * 4 + 3], 26 | a30: number = a[3 * 4 + 0], 27 | a31: number = a[3 * 4 + 1], 28 | a32: number = a[3 * 4 + 2], 29 | a33: number = a[3 * 4 + 3], 30 | b00: number = b[0 * 4 + 0], 31 | b01: number = b[0 * 4 + 1], 32 | b02: number = b[0 * 4 + 2], 33 | b03: number = b[0 * 4 + 3], 34 | b10: number = b[1 * 4 + 0], 35 | b11: number = b[1 * 4 + 1], 36 | b12: number = b[1 * 4 + 2], 37 | b13: number = b[1 * 4 + 3], 38 | b20: number = b[2 * 4 + 0], 39 | b21: number = b[2 * 4 + 1], 40 | b22: number = b[2 * 4 + 2], 41 | b23: number = b[2 * 4 + 3], 42 | b30: number = b[3 * 4 + 0], 43 | b31: number = b[3 * 4 + 1], 44 | b32: number = b[3 * 4 + 2], 45 | b33: number = b[3 * 4 + 3]; 46 | this.data = [ 47 | a00 * b00 + a01 * b10 + a02 * b20 + a03 * b30, 48 | a00 * b01 + a01 * b11 + a02 * b21 + a03 * b31, 49 | a00 * b02 + a01 * b12 + a02 * b22 + a03 * b32, 50 | a00 * b03 + a01 * b13 + a02 * b23 + a03 * b33, 51 | a10 * b00 + a11 * b10 + a12 * b20 + a13 * b30, 52 | a10 * b01 + a11 * b11 + a12 * b21 + a13 * b31, 53 | a10 * b02 + a11 * b12 + a12 * b22 + a13 * b32, 54 | a10 * b03 + a11 * b13 + a12 * b23 + a13 * b33, 55 | a20 * b00 + a21 * b10 + a22 * b20 + a23 * b30, 56 | a20 * b01 + a21 * b11 + a22 * b21 + a23 * b31, 57 | a20 * b02 + a21 * b12 + a22 * b22 + a23 * b32, 58 | a20 * b03 + a21 * b13 + a22 * b23 + a23 * b33, 59 | a30 * b00 + a31 * b10 + a32 * b20 + a33 * b30, 60 | a30 * b01 + a31 * b11 + a32 * b21 + a33 * b31, 61 | a30 * b02 + a31 * b12 + a32 * b22 + a33 * b32, 62 | a30 * b03 + a31 * b13 + a32 * b23 + a33 * b33 63 | ]; 64 | return this; 65 | } 66 | 67 | scale(vec: Vec3): Mat4 { 68 | return this.multiply([ 69 | vec.x, 0, 0, 0, 70 | 0, vec.y, 0, 0, 71 | 0, 0, vec.z, 0, 72 | 0, 0, 0, 1, 73 | ]); 74 | } 75 | 76 | translate(vec: Vec3): Mat4 { 77 | return this.multiply([ 78 | 1, 0, 0, 0, 79 | 0, 1, 0, 0, 80 | 0, 0, 1, 0, 81 | vec.x, vec.y, vec.z, 1 82 | ]); 83 | } 84 | 85 | rotateX(angle: number): Mat4 { 86 | const c = Math.cos(angle); 87 | const s = Math.sin(angle); 88 | return this.multiply([ 89 | 1, 0, 0, 0, 90 | 0, c, s, 0, 91 | 0, -s, c, 0, 92 | 0, 0, 0, 1 93 | ]); 94 | } 95 | 96 | rotateY(angle: number): Mat4 { 97 | const c = Math.cos(angle); 98 | const s = Math.sin(angle); 99 | return this.multiply([ 100 | c, 0, -s, 0, 101 | 0, 1, 0, 0, 102 | s, 0, c, 0, 103 | 0, 0, 0, 1 104 | ]); 105 | } 106 | 107 | rotateZ(angle: number): Mat4 { 108 | const c = Math.cos(angle); 109 | const s = Math.sin(angle); 110 | return this.multiply([ 111 | c, s, 0, 0, 112 | -s, c, 0, 0, 113 | 0, 0, 1, 0, 114 | 0, 0, 0, 1, 115 | ]); 116 | } 117 | 118 | rotate(vec: Vec3): Mat4 { 119 | return this 120 | .rotateX(vec.x) 121 | .rotateY(vec.y) 122 | .rotateZ(vec.z); 123 | } 124 | 125 | invert(): number[] { 126 | let m4 = this.data, 127 | a00 = m4[0], a01 = m4[1], a02 = m4[2], 128 | a10 = m4[4], a11 = m4[5], a12 = m4[6], 129 | a20 = m4[8], a21 = m4[9], a22 = m4[10], 130 | b01 = a22 * a11 - a12 * a21, 131 | b11 = -a22 * a10 + a12 * a20, 132 | b21 = a21 * a10 - a11 * a20, 133 | d = a00 * b01 + a01 * b11 + a02 * b21; 134 | if (!d) { 135 | return null; 136 | } 137 | const id = 1 / d; 138 | const m3 = [ 139 | b01 * id, 140 | (-a22 * a01 + a02 * a21) * id, 141 | (a12 * a01 - a02 * a11) * id, 142 | b11 * id, 143 | (a22 * a00 - a02 * a20) * id, 144 | (-a12 * a00 + a02 * a10) * id, 145 | b21 * id, 146 | (-a21 * a00 + a01 * a20) * id, 147 | (a11 * a00 - a01 * a10) * id 148 | ]; 149 | a01 = m3[1], 150 | a02 = m3[2], 151 | a12 = m3[5]; 152 | m3[1] = m3[3]; 153 | m3[2] = m3[6]; 154 | m3[3] = a01; 155 | m3[5] = m3[7]; 156 | m3[6] = a02; 157 | m3[7] = a12; 158 | return m3; 159 | } 160 | 161 | } 162 | -------------------------------------------------------------------------------- /src/T3D/Mesh.ts: -------------------------------------------------------------------------------- 1 | import { RAD_SCALE, Vec3 } from "./Vec3"; 2 | 3 | class Face { 4 | 5 | verts: Array = []; 6 | normal: Vec3; 7 | normals: Array = []; 8 | 9 | constructor(v1: Vert, v2: Vert, v3: Vert) { 10 | v1.addFace(this); 11 | v2.addFace(this); 12 | v3.addFace(this); 13 | this.verts.push(v1, v2, v3); 14 | this.normal = v2.clone().sub(v1).cross(v3.clone().sub(v1)).normalize(); 15 | } 16 | 17 | calcNormals(angleCos: number): Face { 18 | this.verts.forEach((vert: Vert, i: number) => { 19 | let normal: Vec3; 20 | vert.faces.forEach(face => { 21 | if (this.normal.dot(face.normal) > angleCos) { 22 | normal = normal ? normal.add(face.normal) : face.normal.clone(); 23 | } 24 | }); 25 | this.normals.push(normal ? normal.normalize() : this.normal); 26 | }); 27 | return this; 28 | } 29 | 30 | pushVerts(data: Array): Face { 31 | this.verts.forEach((vec: Vert) => { 32 | data.push(...vec.toArray()); 33 | }); 34 | return this; 35 | } 36 | 37 | pushNormals(data: Array): Face { 38 | this.normals.forEach((vec: Vert) => { 39 | data.push(...vec.toArray()); 40 | }); 41 | return this; 42 | } 43 | 44 | } 45 | 46 | class Vert extends Vec3 { 47 | 48 | faces: Array = []; 49 | 50 | addFace(face: Face) { 51 | this.faces.push(face); 52 | return this; 53 | } 54 | 55 | } 56 | 57 | export class Mesh { 58 | 59 | verts: WebGLBuffer; 60 | normals: WebGLBuffer; 61 | length: number; 62 | 63 | constructor(gl: WebGLRenderingContext, divide: number, path: Array = [], smooth: number = 0, angle: number=360) { 64 | if (divide < 2) { 65 | return; 66 | } 67 | if (path.length < 2) { 68 | path = this.sphere(path.length > 0 ? path[0] + 2 : Math.ceil(divide / 2) + 1); 69 | } 70 | const verts: Array = this.createVerts(divide, path, 0, angle); 71 | const faces: Array = this.createFaces(verts, divide, path.length / 2); 72 | const cos = Math.cos(smooth * RAD_SCALE); 73 | const vertData: number[] = []; 74 | const normalData: number[] = []; 75 | faces.forEach((face: Face) => { 76 | face.calcNormals(cos) 77 | .pushVerts(vertData) 78 | .pushNormals(normalData); 79 | }); 80 | this.verts = gl.createBuffer(); 81 | gl.bindBuffer(gl.ARRAY_BUFFER, this.verts); 82 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertData), gl.STATIC_DRAW); 83 | this.normals = gl.createBuffer(); 84 | gl.bindBuffer(gl.ARRAY_BUFFER, this.normals); 85 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(normalData), gl.STATIC_DRAW); 86 | this.length = vertData.length / 3; 87 | } 88 | 89 | private sphere(divide: number) { 90 | const path = []; 91 | if (divide < 3) { 92 | return; 93 | } 94 | let angle: number = Math.PI / (divide - 1); 95 | for (let i = 1; i < divide - 1; i++) { 96 | let a = angle * i; 97 | path.push(Math.sin(a) / 2); 98 | path.push(Math.cos(a) / 2); 99 | } 100 | return path; 101 | } 102 | 103 | private createVerts(divide: number, path: Array, start: number, end: number): Array { 104 | start *= RAD_SCALE; 105 | end *= RAD_SCALE; 106 | let verts: Array = []; 107 | let angle: number = (end - start) / divide; 108 | verts.push(new Vert(0, .5, 0)); 109 | verts.push(new Vert(0, -.5, 0)); 110 | for (let i = 0; i < divide; i++) { 111 | let a = angle * i + start; 112 | let x = Math.cos(a); 113 | let z = Math.sin(a); 114 | for (let j = 0; j < path.length; j += 2) { 115 | let vert = new Vert(x, 0, z); 116 | vert.scale(path[j]).y = path[j + 1]; 117 | verts.push(vert); 118 | } 119 | } 120 | return verts; 121 | } 122 | 123 | private createFaces(verts: Array, divide: number, length: number): Array { 124 | const faces: Array = []; 125 | let index; 126 | for (let i = 1; i < divide; ++i) { 127 | index = i * length + 2; 128 | faces.push(new Face(verts[0], verts[index], verts[index - length])); 129 | faces.push(new Face(verts[1], verts[index - 1], verts[index + length - 1])); 130 | for (let j = 0; j < length - 1; j++) { 131 | let add = index + j; 132 | faces.push(new Face(verts[add + 1], verts[add - length], verts[add])); 133 | faces.push(new Face(verts[add - length + 1], verts[add - length], verts[add + 1])); 134 | } 135 | } 136 | faces.push(new Face(verts[0], verts[2], verts[index])); 137 | faces.push(new Face(verts[1], verts[index + length - 1], verts[length + 1])); 138 | for (let j = 0; j < length - 1; j++) { 139 | let add = index + j; 140 | faces.push(new Face(verts[j + 3], verts[add], verts[j + 2])); 141 | faces.push(new Face(verts[add + 1], verts[add], verts[j + 3])); 142 | } 143 | return faces; 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /src/T3D/Shader.ts: -------------------------------------------------------------------------------- 1 | export class Shader { 2 | 3 | program: WebGLProgram; 4 | indices: WebGLBuffer; 5 | attribs: object = {}; 6 | location: {[key: string]: any} = {}; 7 | 8 | constructor( 9 | public gl: WebGLRenderingContext, 10 | vertexShader: string, 11 | fragmentShader: string 12 | ) { 13 | this.gl = gl; 14 | this.program = gl.createProgram(); 15 | this.indices = gl.createBuffer(); 16 | const program = this.program; 17 | gl.attachShader(program, this.create(gl.VERTEX_SHADER, vertexShader)); 18 | gl.attachShader(program, this.create(gl.FRAGMENT_SHADER, fragmentShader)); 19 | gl.linkProgram(program); 20 | if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 21 | console.log(gl.getProgramInfoLog(program)); 22 | gl.deleteProgram(program); 23 | } 24 | } 25 | 26 | private create(type: number, source: string): WebGLShader { 27 | const gl = this.gl; 28 | const shader = gl.createShader(type); 29 | gl.shaderSource(shader, source); 30 | gl.compileShader(shader); 31 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 32 | console.log(gl.getShaderInfoLog(shader)); 33 | } 34 | return shader; 35 | } 36 | 37 | attrib(name: string, values: WebGLBuffer, size: number): Shader { 38 | const gl = this.gl; 39 | if (!this.location[name]) { 40 | this.location[name] = gl.getAttribLocation(this.program, name); 41 | } 42 | const location = this.location[name]; 43 | gl.bindBuffer(gl.ARRAY_BUFFER, values); 44 | gl.enableVertexAttribArray(location); 45 | gl.vertexAttribPointer(location, size, gl.FLOAT, false, 0, 0); 46 | return this; 47 | } 48 | 49 | uniform(name: string, value: number): Shader; 50 | uniform(name: string, value: Array): Shader; 51 | uniform(name: string, value: any): Shader { 52 | const gl = this.gl; 53 | if (!this.location[name]) { 54 | this.location[name] = gl.getUniformLocation(this.program, name); 55 | } 56 | const location = this.location[name]; 57 | if (typeof value == 'number') { 58 | gl.uniform1f(location, value); 59 | return this; 60 | } 61 | switch (value.length) { 62 | case 2: gl.uniform2fv(location, value); break; 63 | case 3: gl.uniform3fv(location, value); break; 64 | case 4: gl.uniform4fv(location, value); break; 65 | case 9: gl.uniformMatrix3fv(location, false, value); break; 66 | case 16: gl.uniformMatrix4fv(location, false, value); break; 67 | } 68 | return this; 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/T3D/Transform.ts: -------------------------------------------------------------------------------- 1 | import { Vec3, RAD_SCALE } from "./Vec3"; 2 | import { Mat4 } from "./Mat4"; 3 | 4 | export class Transform { 5 | 6 | scale: Vec3; 7 | rotate: Vec3; 8 | translate: Vec3; 9 | parent: Transform; 10 | 11 | constructor(data: Array = []) { 12 | this.translate = new Vec3(data[0] || 0, data[1] || 0, data[2] || 0); 13 | this.rotate = new Vec3(data[3] || 0, data[4] || 0, data[5] || 0); 14 | this.scale = new Vec3(data[6] || 1, data[7] || 1, data[8] || 1); 15 | } 16 | 17 | matrix(matrix?: Mat4) : Mat4 { 18 | matrix = matrix || new Mat4(); 19 | matrix.scale(this.scale) 20 | .rotate(this.rotate.clone().scale(RAD_SCALE)) 21 | .translate(this.translate); 22 | return this.parent 23 | ? this.parent.matrix(matrix) 24 | : matrix; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/T3D/Vec3.ts: -------------------------------------------------------------------------------- 1 | export const RAD_SCALE = Math.PI / 180; 2 | 3 | export class Vec3 { 4 | 5 | constructor(public x: number = 0, public y: number = 0, public z: number = 0) { 6 | } 7 | 8 | set(vec?: Vec3): Vec3; 9 | set(x?: number, y?: number, z?: number): Vec3; 10 | set(xOrVec?: any, y?: number, z?: number): Vec3 { 11 | if (xOrVec instanceof Vec3) { 12 | this.x = xOrVec.x; 13 | this.y = xOrVec.y; 14 | this.z = xOrVec.z; 15 | return this; 16 | } 17 | if (typeof xOrVec == 'number') { 18 | this.x = xOrVec; 19 | } 20 | if (typeof y == 'number') { 21 | this.y = y; 22 | } 23 | if (typeof z == 'number') { 24 | this.z = z; 25 | } 26 | return this; 27 | } 28 | 29 | max(): number { 30 | return Math.max(this.x, this.y, this.z); 31 | } 32 | 33 | add(vec: Vec3): Vec3 { 34 | this.x += vec.x; 35 | this.y += vec.y; 36 | this.z += vec.z; 37 | return this; 38 | } 39 | 40 | sub(vec: Vec3): Vec3 { 41 | this.x -= vec.x; 42 | this.y -= vec.y; 43 | this.z -= vec.z; 44 | return this; 45 | } 46 | 47 | distance(vec: Vec3): number { 48 | return Math.sqrt( 49 | (this.x - vec.x) * (this.x - vec.x) + 50 | (this.y - vec.y) * (this.y - vec.y) + 51 | (this.z - vec.z) * (this.z - vec.z) 52 | ); 53 | } 54 | 55 | dot(vec: Vec3): number { 56 | return this.x * vec.x + this.y * vec.y + this.z * vec.z; 57 | } 58 | 59 | cross(vec: Vec3): Vec3 { 60 | let x = this.x; 61 | let y = this.y; 62 | let z = this.z; 63 | let vx = vec.x; 64 | let vy = vec.y; 65 | let vz = vec.z; 66 | this.x = y * vz - z * vy; 67 | this.y = z * vx - x * vz; 68 | this.z = x * vy - y * vx; 69 | return this; 70 | }; 71 | 72 | length(): number { 73 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); 74 | } 75 | 76 | scale(value: Vec3): Vec3; 77 | scale(value: number): Vec3; 78 | scale(value: any): Vec3 { 79 | this.x *= value instanceof Vec3 ? value.x : value; 80 | this.y *= value instanceof Vec3 ? value.y : value; 81 | this.z *= value instanceof Vec3 ? value.z : value; 82 | return this; 83 | } 84 | 85 | normalize() { 86 | var length = this.length(); 87 | if (length > 0) { 88 | this.scale(1 / length); 89 | } 90 | return this; 91 | } 92 | 93 | clone(): Vec3 { 94 | return new Vec3(this.x, this.y, this.z); 95 | } 96 | 97 | invert(): Vec3 { 98 | this.x = -this.x; 99 | this.y = -this.y; 100 | this.z = -this.z; 101 | return this; 102 | } 103 | 104 | toArray(): Array { 105 | return [this.x, this.y, this.z]; 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/T3D/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Camera"; 2 | export * from "./Collider"; 3 | export * from "./Item"; 4 | export * from "./Mat4"; 5 | export * from "./Mesh"; 6 | export * from "./Shader"; 7 | export * from "./Transform"; 8 | export * from "./Vec3"; 9 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SPACECRAFT 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 |
  • SPACE
  • 15 |
  • PLUTO
  • 16 |
  • NEPTUNE
  • 17 |
  • URANUS
  • 18 |
  • SATURN
  • 19 |
  • JUPITER
  • 20 |
  • MARS
  • 21 |
  • MOON
  • 22 |
23 | 24 |
25 |
26 |
27 |

28 |

29 |

30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
Distance travelled
Tokens collected
Big tokens collected
Asteroids destroyed
Places visited
Mission completed
TOTAL
39 |
40 | 41 |
42 |
43 |

44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 |

SPACECRAFT

52 |
53 |

54 | JUMP - UP arrow key
55 | SHRINK - DOWN arrow key
56 | MOVE - LEFT / RIGHT arrow keys
57 | BOOST - SPACE key 58 |

59 |

60 | JUMP - Swipe UP
61 | SHRINK - Swipe DOWN
62 | MOVE - Swipe LEFT / RIGHT
63 | BOOST - TAP 64 |

65 |

66 | BIG TOKENS help you collect small ones.
67 | Use SHRINK to go through SPACE JUNK.
68 | Use BOOST to destroy ASTEROIDS. 69 |

70 |
71 | 72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./style.scss"; 2 | import { Menu } from "./Game/Menu"; 3 | import { Hero } from "./Game/Hero"; 4 | import { Scene } from "./Game/Scene"; 5 | import { Platform } from "./Game/Platform"; 6 | import { Enemy } from "./Game/Enemy"; 7 | import { Token } from "./Game/Token"; 8 | import { Map } from "./Game/Map"; 9 | import { Event } from "./Game/Event"; 10 | import SFX, { Channel, Sound } from "./sfx"; 11 | import { Vec3, Camera, Shader, Mesh, Item, Box, Sphere } from "./T3D/index"; 12 | import vertShader from "./shader/tiny.vert"; 13 | import fragShader from "./shader/tiny.frag"; 14 | 15 | export function $(query: string, element?: Element): Element { 16 | return (element || document).querySelector(query); 17 | } 18 | 19 | export function on(element: any, event: string, callback: EventListenerOrEventListenerObject, capture: any = false) { 20 | element.addEventListener(event, callback, capture); 21 | } 22 | 23 | export class Rand { 24 | 25 | static seed: number = Math.random(); 26 | 27 | static get(max: number = 1, min: number = 0, round: boolean = true): number { 28 | if (max <= min) { 29 | return max; 30 | } 31 | Rand.seed = (Rand.seed * 9301 + 49297) % 233280; 32 | let value = min + (Rand.seed / 233280) * (max - min); 33 | return round ? Math.round(value) : value; 34 | } 35 | 36 | } 37 | 38 | export const COLOR = { 39 | WHITE: [1, 1, 1, 10], 40 | PINK: [1, .3, 1, 30], 41 | BLUE: [.3, .3, 1, 30], 42 | YELLOW: [1, 1, .3, 30], 43 | RED: [1, .3, .3, 0], 44 | CYAN: [.3, 1, 1, 30] 45 | }; 46 | 47 | let running: boolean = false, 48 | canvas: HTMLCanvasElement = $('#game'), 49 | menu: Menu = new Menu(), 50 | music: AudioBufferSourceNode, 51 | time: number = new Date().getTime(), 52 | gl: WebGLRenderingContext = canvas.getContext('webgl'), 53 | light: Vec3 = new Vec3(5, 15, 7), 54 | camera: Camera = new Camera(canvas.width / canvas.height), 55 | shader: Shader = new Shader(gl, vertShader, fragShader), 56 | mesh = { 57 | hero: [ 58 | new Mesh(gl, 10), 59 | new Mesh(gl, 10, [.5, .15, .5, .1, .5, -.1, .5, -.15]), 60 | new Mesh(gl, 10, [.2, .5, .48, .2, .5, .1, .2, .1, .2, -.1, .5, -.1, .48, -.2, .2, -.5]), 61 | new Mesh(gl, 10, [.3, .44, .43, .3, .45, .2, .49, .2, .5, .1, .45, .1, .45, -.1, .5, -.1, .49, -.2, .45, -.2, .43, -.3, .3, -.44]), 62 | ], 63 | block: new Mesh(gl, 4, [.55, .5, .65, .4, .65, -.4, .55, -.5]), 64 | fence: new Mesh(gl, 12, [.4, .5, .5, .4, .5, -.4, .4, -.5], 40), 65 | token: new Mesh(gl, 9, [.45, .3, .45, .5, .5, .5, .5, -.5, .45, -.5, .45, -.3], 30), 66 | enemy: new Mesh(gl, 6), 67 | }, 68 | hero: Hero = new Hero(mesh.hero[0], COLOR.WHITE), 69 | scene: Scene = new Scene(hero, () => { 70 | let platform = new Platform(), 71 | block = new Item(mesh.block, COLOR.BLUE, [,,,,45]), 72 | enemy = new Enemy(mesh.enemy, COLOR.CYAN, [,1,,,,,.7,.7,.7]), 73 | token = new Token(mesh.token, COLOR.YELLOW, [,1,,90,,,.5,.1,.5]), 74 | fence = new Item(mesh.fence, COLOR.RED, [,1.4,,,,,.8,1,.8]); 75 | block.collider = new Box(block.transform); 76 | enemy.collider = new Sphere(enemy.transform); 77 | token.collider = new Sphere(token.transform); 78 | fence.collider = new Box(fence.transform); 79 | platform.block = block; 80 | platform.token = token; 81 | platform.fence = fence; 82 | platform.enemy = enemy; 83 | return platform.add(block).add(token).add(fence).add(enemy); 84 | }, new Map( 85 | '311737173711|4111|5711|3111|'+ 86 | '211135012111|2111|301531513510|'+ 87 | '311119973111|5111111d|311120003115|'+ 88 | '551111dd|305130053051|3111139b3511|'+ 89 | '211130002115|401510004510' 90 | )); 91 | 92 | function resize() { 93 | canvas.width = canvas.clientWidth; 94 | canvas.height = canvas.clientHeight; 95 | camera.aspect = canvas.width / canvas.height; 96 | gl.viewport(0, 0, canvas.width, canvas.height); 97 | } 98 | 99 | function bind(): void { 100 | let x: number = 0, 101 | y: number = 0, 102 | min: number = 15, 103 | keys: boolean[] = [], 104 | drag = false; 105 | 106 | on(document, 'touchstart', (e: TouchEvent) => { 107 | let touch = e.touches[0]; 108 | x = touch.clientX; 109 | y = touch.clientY; 110 | drag = true; 111 | }); 112 | 113 | on(document, 'touchmove', (e: TouchEvent) => { 114 | e.preventDefault(); 115 | if (!drag || menu.active) { 116 | return; 117 | } 118 | let touch = e.touches[0]; 119 | if (!keys[39] && touch.clientX - x > min) { 120 | keys[39] = true; 121 | scene.input(39); 122 | drag = false; 123 | } else if (!keys[37] && touch.clientX - x < -min) { 124 | keys[37] = true; 125 | scene.input(37); 126 | drag = false; 127 | } else if (!keys[40] && touch.clientY - y > min) { 128 | keys[40] = true; 129 | scene.input(40); 130 | drag = false; 131 | } else if (!keys[38] && touch.clientY - y < -min) { 132 | keys[38] = true; 133 | scene.input(38); 134 | drag = false; 135 | } 136 | }, {passive: false}); 137 | 138 | on(document, 'touchend', (e: TouchEvent) => { 139 | if (drag && !menu.active) { 140 | keys[32] = true; 141 | scene.input(32); 142 | } 143 | keys[32] = 144 | keys[37] = 145 | keys[38] = 146 | keys[39] = 147 | keys[40] = 148 | drag = false; 149 | }); 150 | 151 | on(document, 'keydown', (e: KeyboardEvent) => { 152 | if (keys[e.keyCode]) { 153 | return; 154 | } 155 | keys[e.keyCode] = true; 156 | if (!running) { 157 | if (keys[32]) { 158 | load(); 159 | } 160 | } else if (menu.active) { 161 | menu.input(e.keyCode); 162 | return; 163 | } else { 164 | scene.input(e.keyCode); 165 | } 166 | }); 167 | 168 | on(document, 'keyup', (e: KeyboardEvent) => { 169 | keys[e.keyCode] = false; 170 | }); 171 | 172 | on(window, 'resize', resize); 173 | 174 | on($('#start'), 'click', () => { 175 | if (!running) { 176 | load(); 177 | } 178 | }); 179 | 180 | Event.on('all', (event) => { 181 | SFX.play(event); 182 | }); 183 | 184 | Event.on('start', () => { 185 | menu.hide(); 186 | scene.init(); 187 | if (!music) { 188 | let mixer = SFX.mixer('music'), 189 | time = mixer.context.currentTime; 190 | mixer.gain.setValueAtTime(menu.volume, time); 191 | music = SFX.play('music', true, 'music'); 192 | } 193 | }); 194 | 195 | Event.on('end', () => { 196 | hero.init(false); 197 | menu.show(); 198 | }); 199 | } 200 | 201 | function render(item: Item, stroke: number = 0) { 202 | item.childs.forEach(child => { 203 | render(child, stroke); 204 | }); 205 | if (!item.active || !item.mesh) { 206 | return; 207 | } 208 | const invert = item.transform.matrix().invert(); 209 | if (!invert) { 210 | return; 211 | } 212 | gl.cullFace(stroke > 0 ? gl.FRONT : gl.BACK); 213 | gl.useProgram(shader.program); 214 | shader.attrib("aPos", item.mesh.verts, 3) 215 | .attrib("aNorm", item.mesh.normals, 3) 216 | .uniform("uWorld", camera.transform(item.transform).data) 217 | .uniform("uProj", camera.perspective().data) 218 | .uniform("uInverse", invert) 219 | .uniform("uColor", stroke ? [0, 0, 0, 1] : item.color) 220 | .uniform("uLight", light.clone().sub(camera.position).toArray()) 221 | .uniform("uStroke", stroke + item.stroke); 222 | gl.drawArrays(gl.TRIANGLES, 0, item.mesh.length); 223 | } 224 | 225 | function update() { 226 | requestAnimationFrame(update); 227 | gl.clear(gl.COLOR_BUFFER_BIT); 228 | if (menu.shop) { 229 | hero.mesh = mesh.hero[menu.selected]; 230 | hero.preview(); 231 | render(hero); 232 | render(hero, .01); 233 | return; 234 | } 235 | let now = new Date().getTime(); 236 | if (now - time > 30) { 237 | scene.update(); 238 | } 239 | time = now; 240 | scene.update(); 241 | render(scene); 242 | render(scene, .01); 243 | if (!hero.active && music) { 244 | let mixer = SFX.mixer('music'), 245 | time = mixer.context.currentTime; 246 | mixer.gain.setValueCurveAtTime(Float32Array.from([menu.volume, 0]), time, .5); 247 | music.stop(time + .5); 248 | music = null; 249 | } 250 | if (!menu.active && scene.ended()) { 251 | menu.score(hero); 252 | } 253 | } 254 | 255 | async function load() { 256 | running = true; 257 | let btn = $('#start'); 258 | btn.className = 'disabled'; 259 | btn.textContent = 'loading'; 260 | await SFX.init(); 261 | await Promise.all([ 262 | SFX.sound('exp', new Sound('custom', [5, 1, 0], 1), [220, 0], 1), 263 | SFX.sound('hit', new Sound('custom', [3, 1, 0], 1), [1760, 0], .3), 264 | SFX.sound('power', new Sound('square', [.5, .1, 0], 1), [440, 880, 440, 880, 440, 880, 440, 880], .3), 265 | SFX.sound('jump', new Sound('triangle', [.5, .1, 0], 1), [220, 880], .3), 266 | SFX.sound('coin', new Sound('square', [.2, .1, 0], .2), [1760, 1760], .2), 267 | SFX.sound('move', new Sound('custom', [.1, .5, 0], .3), [1760, 440], .3), 268 | SFX.music('music', [ 269 | new Channel(new Sound('sawtooth', [1, .3], .2), '8a2,8a2,8b2,8c3|8|8g2,8g2,8a2,8b2|8|8e2,8e2,8f2,8g2|4|8g2,8g2,8a2,8b2|4|'.repeat(4), 1), 270 | new Channel(new Sound('sawtooth', [.5, .5], 1), '1a3,1g3,2e3,4b3,4c4,1a3c3e3,1g3b3d4,2e3g3b3,4d3g3b3,4g3c4e4|1|'+'8a3,8a3e4,8a3d4,8a3e4|2|8g3,8g3d4,8g3c4,8g3d4|2|8e3,8e3a3,8e3b3,8e3a3,4g3b3,4g3c4|1|'.repeat(2), 4) 271 | ]) 272 | ]); 273 | $('#load').className = 'hide'; 274 | update(); 275 | } 276 | 277 | on(window, 'load', async () => { 278 | hero.init(); 279 | gl.clearColor(0, 0, 0, 0); 280 | gl.enable(gl.CULL_FACE); 281 | gl.enable(gl.DEPTH_TEST); 282 | camera.rotate.x = -.7; 283 | camera.position.set(0, 0, 1.2); 284 | hero.transform.rotate.set(10, 22, 30); 285 | render(hero); 286 | render(hero, .02); 287 | ($("link[rel=apple-touch-icon]")).href = 288 | ($("link[rel=icon]")).href = canvas.toDataURL(); 289 | camera.position.set(0, .5, 5); 290 | resize(); 291 | bind(); 292 | }); 293 | 294 | $('ontouchstart' in window ? '#keys' : '#touch').className = 'hide'; 295 | -------------------------------------------------------------------------------- /src/sfx.ts: -------------------------------------------------------------------------------- 1 | declare var window: any; 2 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 3 | window.OfflineAudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext; 4 | 5 | const out = new AudioContext(); 6 | const gains: { [id: string]: GainNode } = {}; 7 | const buffers : { [id: string]: AudioBuffer } = {}; 8 | const keys: {[key: string]: number} = { c: 0, db: 1, d: 2, eb: 3, e: 4, f: 5, gb: 6, g: 7, ab: 8, a: 9, bb: 10, b: 11 }; 9 | const freq: number[] = []; 10 | const bitrate: number = 44100; 11 | let noise: AudioBuffer; 12 | 13 | export class Sound { 14 | 15 | curve: Float32Array; 16 | 17 | constructor( 18 | public type: OscillatorType, 19 | curve: number[], 20 | public length: number, 21 | ) { 22 | this.curve = Float32Array.from(curve); 23 | } 24 | 25 | getTime(max: number): number { 26 | return (max < this.length ? max : this.length) - .01; 27 | } 28 | 29 | } 30 | 31 | export class Channel { 32 | 33 | public size: number = 0; 34 | public length: number = 0; 35 | private data: number[][] = []; 36 | 37 | constructor( 38 | public inst: Sound, 39 | notes: string, 40 | tempo: number 41 | ) { 42 | let sheet = notes.split("|"); 43 | if (sheet.length > 1) { 44 | notes = ""; 45 | for (let i = 0; i < sheet.length; i++) { 46 | notes += i % 2 47 | ? ("," + sheet[i-1]).repeat(parseInt(sheet[i]) - 1) 48 | : (notes ? "," : "") + sheet[i]; 49 | } 50 | } 51 | notes.split(",").forEach((code) => { 52 | let div = code.match(/^(\d+)/), 53 | freqs = code.match(/([a-z]+\d+)/g); 54 | if (div) { 55 | let time = tempo / parseInt(div[1]), 56 | row = [time]; 57 | this.length += time; 58 | if (freqs) { 59 | if (freqs.length > this.size) { 60 | this.size = freqs.length; 61 | } 62 | for (let i = 0; i < freqs.length; i++) { 63 | let note = freqs[i].match(/([a-z]+)(\d+)/); 64 | if (note) { 65 | row.push(freq[parseInt(note[2]) * 12 + keys[note[1]]]); 66 | } 67 | } 68 | } 69 | this.data.push(row); 70 | } 71 | }); 72 | } 73 | 74 | play(ctx: OfflineAudioContext) { 75 | let length = 0; 76 | const inst = this.inst; 77 | const vol = ctx.createGain(); 78 | const osc: OscillatorNode[] = []; 79 | vol.connect(ctx.destination); 80 | for (let i = 0; i < this.size; i++) { 81 | osc[i] = ctx.createOscillator(); 82 | osc[i].type = inst.type; 83 | osc[i].connect(vol); 84 | } 85 | this.data.forEach(note => { 86 | if (inst.curve) { 87 | vol.gain.setValueCurveAtTime(inst.curve, length, inst.getTime(note[0])); 88 | } 89 | osc.forEach((o, i) => { 90 | o.frequency.setValueAtTime(note[i + 1] || 0, length); 91 | }); 92 | length += note[0]; 93 | }); 94 | osc.forEach(o => { 95 | o.start(); 96 | o.stop(length); 97 | }); 98 | } 99 | 100 | } 101 | 102 | export default { 103 | 104 | async init(): Promise { 105 | if (out.state === "suspended") { 106 | await out.resume(); 107 | } 108 | const a = Math.pow(2, 1 / 12); 109 | for (let n = -57; n < 50; n++) { 110 | freq.push(Math.pow(a, n) * 440); 111 | } 112 | noise = out.createBuffer(1, bitrate * 2, bitrate); 113 | const output = noise.getChannelData(0); 114 | for (let i = 0; i < bitrate * 2; i++) { 115 | output[i] = Math.random() * 2 - 1; 116 | } 117 | }, 118 | 119 | async sound(id: string, sound: Sound, freq: number[], time: number): Promise { 120 | const ctx = new OfflineAudioContext(1, bitrate * time, bitrate); 121 | const vol = ctx.createGain(); 122 | const curve = Float32Array.from(freq); 123 | vol.connect(ctx.destination); 124 | if (sound.curve) { 125 | vol.gain.setValueCurveAtTime(sound.curve, 0, sound.getTime(time)); 126 | } 127 | ctx.addEventListener("complete", (e) => buffers[id] = e.renderedBuffer); 128 | if (sound.type == "custom") { 129 | const filter = ctx.createBiquadFilter(); 130 | filter.connect(vol); 131 | filter.detune.setValueCurveAtTime(curve, 0, time); 132 | const src = ctx.createBufferSource(); 133 | src.buffer = noise; 134 | src.loop = true; 135 | src.connect(filter); 136 | src.start(); 137 | } else { 138 | const osc = ctx.createOscillator(); 139 | osc.type = sound.type; 140 | osc.frequency.setValueCurveAtTime(curve, 0, time); 141 | osc.connect(vol); 142 | osc.start(); 143 | osc.stop(time); 144 | } 145 | await ctx.startRendering(); 146 | }, 147 | 148 | async music(id: string, channels: Channel[]): Promise { 149 | const length = channels.reduce((length, channel) => channel.length > length ? channel.length : length, 0); 150 | const ctx = new OfflineAudioContext(1, bitrate * length, bitrate); 151 | ctx.addEventListener("complete", (e) => buffers[id] = e.renderedBuffer); 152 | channels.forEach((channel, i) => channel.play(ctx)); 153 | await ctx.startRendering(); 154 | }, 155 | 156 | mixer(id: string): GainNode { 157 | if (!(id in gains)) { 158 | gains[id] = out.createGain(); 159 | gains[id].connect(out.destination); 160 | } 161 | return gains[id]; 162 | }, 163 | 164 | play(id: string, loop: boolean = false, mixerId: string = "master"): AudioBufferSourceNode { 165 | if (id in buffers) { 166 | let src = out.createBufferSource(); 167 | src.loop = loop; 168 | src.buffer = buffers[id]; 169 | src.connect(this.mixer(mixerId)); 170 | src.start(); 171 | return src; 172 | } 173 | return null; 174 | } 175 | 176 | } 177 | -------------------------------------------------------------------------------- /src/shader/tiny.frag: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | uniform mat4 uWorld; 3 | uniform vec4 uColor; 4 | uniform vec3 uLight; 5 | varying vec4 vPos; 6 | varying vec3 vNorm; 7 | vec3 uAmbient = vec3(.2, .2, .2); 8 | vec3 uDiffuse = vec3(.8, .8, .8); 9 | vec3 uSpecular = vec3(.8, .8, .8); 10 | void main(void) { 11 | vec3 lightDir = normalize(uLight - vPos.xyz); 12 | vec3 normal = normalize(vNorm); 13 | vec3 eyeDir = normalize(-vPos.xyz); 14 | vec3 reflectionDir = reflect(-lightDir, normal); 15 | float specularWeight = 0.0; 16 | if (uColor.w > 0.0) { specularWeight = pow(max(dot(reflectionDir, eyeDir), 0.0), uColor.w); } 17 | float diffuseWeight = max(dot(normal, lightDir), 0.0); 18 | vec3 weight = uAmbient + uSpecular * specularWeight + uDiffuse * diffuseWeight; 19 | vec3 color = uColor.xyz * weight; 20 | gl_FragColor = vec4(color, 1); 21 | } 22 | -------------------------------------------------------------------------------- /src/shader/tiny.vert: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | attribute vec3 aPos, aNorm; 3 | uniform mat4 uWorld, uProj; 4 | uniform mat3 uInverse; 5 | uniform float uStroke; 6 | varying vec4 vPos; 7 | varying vec3 vNorm; 8 | void main(void) { 9 | vec3 pos = aPos + (aNorm * uStroke); 10 | vPos = uWorld * vec4(pos, 1.0); 11 | vNorm = uInverse * aNorm; 12 | gl_Position = uProj * vPos; 13 | } 14 | -------------------------------------------------------------------------------- /src/style.scss: -------------------------------------------------------------------------------- 1 | body, html, canvas, ul, li { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | body, html, canvas, table, tr, td, th, ul, li, h1, h2, h3, h4, #ctrl { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body, html { 12 | overflow: hidden; 13 | background-color: #000; 14 | color: #fff; 15 | font: normal 14px Arial, Helvetica, sans-serif; 16 | text-align: center; 17 | } 18 | 19 | #hud, #game, #planet, #ctrl, #load { 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | right: 0; 24 | bottom: 0; 25 | } 26 | 27 | #load { 28 | background-image:linear-gradient(#003, #033 50%, #000 50%); 29 | opacity: 1; 30 | 31 | &.hide { 32 | display: none; 33 | } 34 | } 35 | 36 | #load, 37 | #hud { 38 | display: flex; 39 | flex-wrap: wrap; 40 | align-items: center; 41 | justify-content: space-between; 42 | 43 | & div { 44 | width: 100%; 45 | } 46 | 47 | & div:first-child { 48 | align-self: flex-start; 49 | } 50 | 51 | & div:last-child { 52 | align-self: flex-end; 53 | } 54 | } 55 | 56 | ul { 57 | 58 | list-style: none; 59 | 60 | & > li { 61 | position: absolute; 62 | letter-spacing: 3px; 63 | line-height: 60px; 64 | font-size: 24px; 65 | font-weight: bold; 66 | } 67 | 68 | & > li.hide { 69 | opacity: 0; 70 | transition: opacity 1s; 71 | } 72 | } 73 | 74 | h1 { 75 | font-size: 24px; 76 | letter-spacing: 5px; 77 | line-height: 80px; 78 | } 79 | 80 | h2 { 81 | font-size: 18px; 82 | line-height: 30px; 83 | } 84 | 85 | h3 { 86 | height: 40px; 87 | font-size: 18px; 88 | } 89 | 90 | h4 { 91 | line-height: 20px; 92 | font-size: 14px; 93 | 94 | &.title { 95 | line-height: 25px; 96 | font-size: 20px; 97 | } 98 | 99 | &.done { 100 | text-decoration: line-through; 101 | } 102 | 103 | } 104 | 105 | p { 106 | margin: 10px 0; 107 | padding: 0; 108 | } 109 | 110 | table { 111 | min-width: 250px; 112 | margin: 5px auto 0; 113 | } 114 | 115 | th { 116 | text-align: left; 117 | font-weight: normal; 118 | } 119 | 120 | td { 121 | text-align: right; 122 | } 123 | 124 | .total { 125 | font-weight: bold; 126 | font-size: 18px; 127 | line-height: 25px; 128 | } 129 | 130 | #quest { 131 | padding: 10px 0; 132 | background: rgba(0, 0, 0, .7); 133 | opacity: 1; 134 | visibility: hidden; 135 | } 136 | 137 | #ctrl { 138 | display: flex; 139 | flex-wrap: wrap; 140 | align-items: flex-end; 141 | justify-content: center; 142 | 143 | & div { 144 | flex: 1 0 100% 145 | } 146 | } 147 | 148 | #keys, 149 | #touch { 150 | &.hide { 151 | display: none; 152 | } 153 | } 154 | 155 | i { 156 | position: absolute; 157 | display: block; 158 | top: 12px; 159 | width: 32px; 160 | line-height: 32px; 161 | font-size: 24px; 162 | font-style: normal; 163 | border-radius: 50%; 164 | cursor: pointer; 165 | color: #999; 166 | } 167 | 168 | #sfx{ 169 | right: 10px; 170 | 171 | &::before { 172 | content: "♫"; 173 | } 174 | &.no::before { 175 | content: "♪"; 176 | text-decoration: line-through; 177 | } 178 | &.sfx::before { 179 | content: "♪"; 180 | } 181 | } 182 | 183 | #fs { 184 | left: 10px; 185 | 186 | &::before { 187 | content: "☐"; 188 | } 189 | } 190 | 191 | a { 192 | display: inline-block; 193 | width: 50px; 194 | line-height: 48px; 195 | font-weight: bold; 196 | color: #000; 197 | text-shadow: 1px 1px 1px #fff; 198 | background-image: linear-gradient(#fff, #ccc); 199 | border: 2px solid #333; 200 | border-radius: 25px; 201 | padding: 0 20px; 202 | margin: 20px 5px; 203 | cursor: pointer; 204 | 205 | &.disabled { 206 | color: #999; 207 | } 208 | } 209 | 210 | .cyan { 211 | color: #2ff; 212 | } 213 | 214 | .pink { 215 | color: #f2f; 216 | } 217 | 218 | .red { 219 | color: #f22; 220 | } 221 | 222 | .end #ctrl, 223 | .play #ctrl, 224 | .play a { 225 | visibility: hidden; 226 | } 227 | 228 | .end #quest, 229 | .play #quest { 230 | visibility: visible; 231 | } 232 | 233 | .play #quest { 234 | opacity: 0; 235 | transition: opacity 1s; 236 | transition-delay: 2.5s; 237 | } 238 | 239 | .play table { 240 | display: none; 241 | } 242 | 243 | * { 244 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 245 | -webkit-touch-callout: none; 246 | user-select: none; 247 | } 248 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "noImplicitAny": true, 5 | "removeComments": true, 6 | "sourceMap": true, 7 | "lib": [ 8 | "dom", 9 | "es2017" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.frag' { 2 | const content: string; 3 | export default content; 4 | } 5 | 6 | declare module '*.vert' { 7 | const content: string; 8 | export default content; 9 | } 10 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); 5 | const CleanWebpackPlugin = require("clean-webpack-plugin"); 6 | const TerserWebpackPlugin = require("terser-webpack-plugin"); 7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 8 | const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin"); 9 | const { execFile } = require("child_process"); 10 | const advzip = require("advzip-bin"); 11 | const path = require("path"); 12 | const fs = require("fs"); 13 | 14 | const distDir = path.resolve(__dirname, "dist"); 15 | const isDev = process.env.npm_lifecycle_event !== "build"; 16 | 17 | class AdvzipPlugin { 18 | 19 | constructor(options) { 20 | this.opt = options; 21 | } 22 | 23 | apply(compiler) { 24 | compiler.hooks.done.tap("advzip", () => { 25 | if (this.opt.disabled) { 26 | return; 27 | } 28 | const args = ["-a4", this.opt.out].concat(this.opt.files); 29 | execFile(advzip, args, {cwd: this.opt.cwd}, () => { 30 | const out = path.resolve(this.opt.cwd, this.opt.out); 31 | const stats = fs.statSync(out); 32 | console.log(`Advzip: ${stats.size}`); 33 | }); 34 | }); 35 | } 36 | } 37 | 38 | module.exports = { 39 | 40 | entry: "./src/main.ts", 41 | 42 | resolve: { 43 | extensions: [".js", ".ts", ".tsx"] 44 | }, 45 | 46 | devtool: "source-map", 47 | 48 | output: { 49 | path: distDir, 50 | filename: "main_bundle.js" 51 | }, 52 | 53 | module: { 54 | rules: [ 55 | { 56 | test: /\.tsx?$/, 57 | loader: "ts-loader" 58 | }, 59 | { 60 | test: /\.scss$/, 61 | use: [ 62 | { 63 | loader: MiniCssExtractPlugin.loader, 64 | options: { 65 | hmr: isDev, 66 | sourceMap: isDev 67 | } 68 | }, 69 | { 70 | loader: "css-loader", 71 | options: { 72 | sourceMap: isDev 73 | } 74 | }, 75 | { 76 | loader: "sass-loader", 77 | options: { 78 | sourceMap: isDev 79 | } 80 | } 81 | ] 82 | }, 83 | { 84 | test: /\.vert$/, 85 | use: { 86 | loader: "glsl-minify-loader", 87 | options: { 88 | shaderType: "vertex" 89 | } 90 | } 91 | }, 92 | { 93 | test: /\.frag$/, 94 | use: { 95 | loader: "glsl-minify-loader", 96 | options: { 97 | shaderType: "fragment" 98 | } 99 | } 100 | } 101 | ] 102 | }, 103 | 104 | devServer: { 105 | contentBase: distDir, 106 | stats: "minimal", 107 | overlay: true 108 | }, 109 | 110 | optimization: { 111 | minimizer: [ 112 | new TerserWebpackPlugin(), 113 | new OptimizeCSSAssetsPlugin() 114 | ] 115 | }, 116 | 117 | plugins: [ 118 | new CleanWebpackPlugin({ 119 | dry: isDev 120 | }), 121 | new MiniCssExtractPlugin(), 122 | new HtmlWebpackPlugin({ 123 | template: "src/index.html", 124 | inlineSource: ".(js|css)$", 125 | minify: { 126 | collapseWhitespace: !isDev 127 | } 128 | }), 129 | new HtmlWebpackInlineSourcePlugin(), 130 | new AdvzipPlugin({ 131 | cwd: distDir, 132 | out: "game.zip", 133 | files: ["index.html"], 134 | disabled: isDev 135 | }) 136 | ] 137 | }; 138 | --------------------------------------------------------------------------------