summaryrefslogtreecommitdiffstats
path: root/src/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/controller/collision.ts65
-rw-r--r--src/renderer/controller/entitycontext.ts47
-rw-r--r--src/renderer/controller/gamecontext.ts174
-rw-r--r--src/renderer/custom.d.ts9
-rw-r--r--src/renderer/index.css20
-rw-r--r--src/renderer/index.ts21
-rw-r--r--src/renderer/math/circle.ts15
-rw-r--r--src/renderer/math/collision.ts7
-rw-r--r--src/renderer/math/line.ts141
-rw-r--r--src/renderer/math/point.ts40
-rw-r--r--src/renderer/model/data/collision.ts12
-rw-r--r--src/renderer/model/data/entity.ts29
-rw-r--r--src/renderer/model/data/map.ts29
-rw-r--r--src/renderer/util.ts84
-rw-r--r--src/renderer/view/entity.ts72
-rw-r--r--src/renderer/view/input/gameinput.ts78
-rw-r--r--src/renderer/view/input/inputhandler.ts44
-rw-r--r--src/renderer/view/map.ts173
-rw-r--r--src/renderer/view/renderer/renderer.ts99
-rw-r--r--src/renderer/view/renderer/shaders.ts79
-rw-r--r--src/renderer/view/renderer/shaders/default.fs10
-rw-r--r--src/renderer/view/renderer/shaders/default.vs13
-rw-r--r--src/renderer/view/sprite.ts72
-rw-r--r--src/renderer/view/util/image.ts33
24 files changed, 1366 insertions, 0 deletions
diff --git a/src/renderer/controller/collision.ts b/src/renderer/controller/collision.ts
new file mode 100644
index 0000000..a44ffa4
--- /dev/null
+++ b/src/renderer/controller/collision.ts
@@ -0,0 +1,65 @@
+import { Collision } from '../model/data/collision';
+
+import { Circle } from '../math/circle';
+import { Collidable } from '../math/collision';
+import { LineSegment, Movement } from '../math/line';
+import { Point } from '../math/point';
+
+import { vec2 } from 'gl-matrix';
+
+export function mkCollision(collision: Collision[]): Collidable[] {
+ const ret: Collidable[] = [];
+
+ for (const c of collision) {
+ switch (c.type) {
+ case 'polygon':
+ if (!c.vertices.length)
+ continue;
+
+ let prev = c.vertices[c.vertices.length - 1];
+
+ for (const v of c.vertices) {
+ ret.push(LineSegment.fromPoints(vec2.clone(prev), vec2.clone(v)));
+ prev = v;
+ }
+
+ for (const v of c.vertices) {
+ ret.push(new Point(vec2.clone(v)));
+ prev = v;
+ }
+ break;
+
+ case 'circle':
+ ret.push(new Circle(vec2.clone(c.center), c.radius));
+ break;
+ }
+ }
+
+ return ret;
+}
+
+export interface CollidableGroup {
+ getTranslation(): vec2|null;
+ getCollidables(): Collidable[];
+}
+
+export function collide(collision: CollidableGroup, out: vec2, move: Movement, radius: number): boolean {
+ const t = collision.getTranslation();
+ if (t)
+ move = move.translate(vec2.negate(vec2.create(), t));
+
+ for (const c of collision.getCollidables()) {
+ if (!c.collide(out, move, radius))
+ continue;
+
+ if (vec2.squaredDistance(move.src, out) >= vec2.squaredDistance(move.src, move.dest))
+ continue;
+
+ if (t)
+ vec2.add(out, out, t);
+
+ return true;
+ }
+
+ return false;
+}
diff --git a/src/renderer/controller/entitycontext.ts b/src/renderer/controller/entitycontext.ts
new file mode 100644
index 0000000..434bf9b
--- /dev/null
+++ b/src/renderer/controller/entitycontext.ts
@@ -0,0 +1,47 @@
+import { EntityView } from '../view/entity';
+import { Renderer } from '../view/renderer/renderer';
+
+import { Collidable } from '../math/collision';
+
+import { CollidableGroup, mkCollision } from './collision';
+
+import { vec2 } from 'gl-matrix';
+
+export class EntityContext implements CollidableGroup {
+ public static async load(renderer: Renderer, name: string): Promise<EntityContext> {
+ return new EntityContext(
+ renderer,
+ name,
+ await EntityView.load(renderer, name),
+ );
+ }
+
+ public readonly pos: vec2 = vec2.create();
+
+ private readonly collision: Collidable[];
+
+ private constructor(
+ private readonly renderer: Renderer,
+ private readonly name: string,
+ private readonly view: EntityView,
+ ) {
+ this.collision = mkCollision(view.data.collision);
+ }
+
+ public render(time: number) {
+ this.renderer.setTranslation(this.pos);
+ this.view.renderByTime(time);
+ }
+
+ public getTranslation(): vec2 {
+ return this.pos;
+ }
+
+ public getCollidables(): Collidable[] {
+ return this.collision;
+ }
+
+ public interact() {
+ alert(`You've interacted with ${this.name}!`);
+ }
+}
diff --git a/src/renderer/controller/gamecontext.ts b/src/renderer/controller/gamecontext.ts
new file mode 100644
index 0000000..9a68e25
--- /dev/null
+++ b/src/renderer/controller/gamecontext.ts
@@ -0,0 +1,174 @@
+import { CollidableGroup, collide, mkCollision } from './collision';
+import { EntityContext } from './entitycontext';
+
+import { MapData } from '../model/data/map';
+
+import { ButtonCode, GameInputHandler } from '../view/input/gameinput';
+import { MapView } from '../view/map';
+import { Renderer } from '../view/renderer/renderer';
+
+import { Collidable } from '../math/collision';
+import { Movement } from '../math/line';
+import { getJSON, nextAnimationFrame } from '../util';
+
+import { vec2 } from 'gl-matrix';
+
+export class GameContext implements CollidableGroup {
+ public static async load(renderer: Renderer): Promise<GameContext> {
+ const map = this.loadMap(renderer, 'test');
+ const loadPlayer = EntityContext.load(renderer, 'green_circle');
+ const loadEntity = EntityContext.load(renderer, 'red_circle');
+
+ const [mapView, mapCollision] = await map;
+ const player = await loadPlayer;
+ const entity = await loadEntity;
+
+ vec2.set(player.pos, 7, 6);
+ vec2.set(entity.pos, 4, 3);
+
+ return new GameContext(
+ renderer,
+ mapView,
+ player,
+ [entity],
+ mapCollision,
+ );
+ }
+
+ private static async loadMap(renderer: Renderer, name: string): Promise<[MapView, Collidable[]]> {
+ const map = new MapData(await getJSON(`resources/map/${name}.json`));
+ return [await MapView.load(renderer, map), mkCollision(map.collision)];
+ }
+
+ private readonly initTime: number = performance.now();
+ private time = 0;
+
+ private readonly tick = 10; // ms per tick
+ private readonly maxSpeed = 0.04; // movement per tick
+ private readonly maxSkip = 20; // maximum ticks to process in a single render step
+
+ private readonly input: GameInputHandler;
+
+ private readonly playerDir: vec2 = vec2.fromValues(0, 1);
+ private speed: number = 0;
+
+ private readonly collisionRadius = 15 / 32;
+ private readonly interactLength = 1 / 32;
+
+ private constructor(
+ private readonly renderer: Renderer,
+ private readonly mapView: MapView,
+ private readonly player: EntityContext,
+ private readonly entities: EntityContext[],
+ private readonly collision: Collidable[],
+ ) {
+ this.input = new GameInputHandler();
+ this.input.addListener((input) => {
+ switch (input.type) {
+ case 'button':
+ if (input.button === ButtonCode.Action)
+ this.interact();
+ break;
+
+ case 'direction':
+ if (vec2.sqrLen(input.direction) > 0) {
+ vec2.copy(this.playerDir, input.direction);
+ this.speed = this.maxSpeed;
+ } else {
+ this.speed = 0;
+ }
+ break;
+ }
+ });
+
+ this.renderLoop();
+ }
+
+ public getTranslation(): null {
+ return null;
+ }
+
+ public getCollidables(): Collidable[] {
+ return this.collision;
+ }
+
+ private updateTime(time: number): number {
+ const diff = Math.round(time / this.tick) - Math.round(this.time / this.tick);
+ this.time = time;
+
+ return diff;
+ }
+
+ private canInteract(c: CollidableGroup): boolean {
+ const dest = vec2.scaleAndAdd(vec2.create(), this.player.pos, this.playerDir, this.interactLength);
+ const move = new Movement(this.player.pos, dest);
+
+ return collide(c, vec2.create(), move, this.collisionRadius);
+ }
+
+ private interact(): void {
+ for (const e of this.entities) {
+ if (!this.canInteract(e))
+ continue;
+
+ e.interact();
+ break;
+ }
+ }
+
+ private updateStepCollide(out: vec2, dest: vec2): boolean {
+ const move = new Movement(this.player.pos, dest);
+
+ for (const c of [this, ...this.entities]) {
+ if (collide(c, out, move, this.collisionRadius)) {
+ if (vec2.squaredDistance(move.src, out) >= vec2.squaredDistance(move.src, move.dest))
+ continue;
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private updateStep(): void {
+ const dest = vec2.scaleAndAdd(vec2.create(), this.player.pos, this.playerDir, this.speed);
+ const newDest = vec2.create();
+
+ while (this.updateStepCollide(newDest, dest)) {
+ if (vec2.equals(newDest, this.player.pos))
+ return;
+
+ vec2.copy(dest, newDest);
+ }
+
+ vec2.copy(this.player.pos, dest);
+ }
+
+ private update(time: number): void {
+ const diff = Math.min(this.maxSkip, this.updateTime(time));
+
+ if (!this.speed) {
+ this.renderer.snapToGrid(this.player.pos, this.player.pos);
+ return;
+ }
+
+ for (let i = 0; i < diff; i++)
+ this.updateStep();
+ }
+
+ private render(): void {
+ this.renderer.setCenter(this.player.pos);
+ this.renderer.clear();
+
+ for (const r of [this.mapView, ...this.entities, this.player])
+ r.render(this.time);
+ }
+
+ private async renderLoop(): Promise<void> {
+ while (true) {
+ this.update(await nextAnimationFrame() - this.initTime);
+ this.render();
+ }
+ }
+}
diff --git a/src/renderer/custom.d.ts b/src/renderer/custom.d.ts
new file mode 100644
index 0000000..b54bb07
--- /dev/null
+++ b/src/renderer/custom.d.ts
@@ -0,0 +1,9 @@
+declare module "*.vs" {
+ const content: string;
+ export default content;
+}
+
+declare module "*.fs" {
+ const content: string;
+ export default content;
+}
diff --git a/src/renderer/index.css b/src/renderer/index.css
new file mode 100644
index 0000000..afdf217
--- /dev/null
+++ b/src/renderer/index.css
@@ -0,0 +1,20 @@
+html, body, div {
+ width: 100%;
+ height: 100%;
+}
+
+#app {
+ text-align: center;
+}
+
+canvas {
+ position: relative;
+ top: calc(50% - 384px);
+}
+
+* {
+ margin: 0px;
+ padding: 0px;
+ border: 0px;
+ background: #223;
+}
diff --git a/src/renderer/index.ts b/src/renderer/index.ts
new file mode 100644
index 0000000..039c3b6
--- /dev/null
+++ b/src/renderer/index.ts
@@ -0,0 +1,21 @@
+import './index.css';
+
+import { GameContext } from './controller/gamecontext';
+
+import { Renderer } from './view/renderer/renderer';
+
+window.onload = async () => {
+ const app = document.getElementById('app');
+ if (!app)
+ return;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = 1024;
+ canvas.height = 768;
+
+ app.append(canvas);
+
+ const renderer = new Renderer(canvas);
+
+ GameContext.load(renderer);
+};
diff --git a/src/renderer/math/circle.ts b/src/renderer/math/circle.ts
new file mode 100644
index 0000000..f4e855a
--- /dev/null
+++ b/src/renderer/math/circle.ts
@@ -0,0 +1,15 @@
+import { Collidable } from './collision';
+import { Movement } from './line';
+import { Point } from './point';
+
+import { vec2 } from 'gl-matrix';
+
+export class Circle implements Collidable, Point {
+ private pointCollide = Point.prototype.collide;
+
+ constructor(public readonly p: vec2, public readonly r: number) {}
+
+ public collide(out: vec2, move: Movement, r: number): boolean {
+ return this.pointCollide(out, move, r + this.r);
+ }
+}
diff --git a/src/renderer/math/collision.ts b/src/renderer/math/collision.ts
new file mode 100644
index 0000000..3df811a
--- /dev/null
+++ b/src/renderer/math/collision.ts
@@ -0,0 +1,7 @@
+import { Movement } from './line';
+
+import { vec2 } from 'gl-matrix';
+
+export interface Collidable {
+ collide(out: vec2, move: Movement, r: number): boolean;
+}
diff --git a/src/renderer/math/line.ts b/src/renderer/math/line.ts
new file mode 100644
index 0000000..db99035
--- /dev/null
+++ b/src/renderer/math/line.ts
@@ -0,0 +1,141 @@
+import { mat2, vec2 } from 'gl-matrix';
+import { Collidable } from './collision';
+
+const rot90 = mat2.fromValues(
+ 0, 1,
+ -1, 0,
+);
+
+export function normal(out: vec2, a: vec2): vec2 {
+ return vec2.transformMat2(out, a, rot90);
+}
+
+export function crossz(a: vec2, b: vec2): number {
+ return a[0] * b[1] - a[1] * b[0];
+}
+
+export class Line {
+ constructor(
+ public readonly p: vec2,
+ public readonly v: vec2,
+ ) {}
+
+ public getNormal(out: vec2): vec2 {
+ return normal(out, this.v);
+ }
+
+ public projectPointDistance(p2: vec2): number {
+ const v2 = vec2.sub(vec2.create(), p2, this.p);
+ return vec2.dot(this.v, v2);
+ }
+
+ public projectPoint(out: vec2, p2: vec2): vec2 {
+ const d = this.projectPointDistance(p2);
+ return vec2.scaleAndAdd(out, this.p, this.v, d);
+ }
+
+ public distancePoint(p2: vec2): number {
+ const v2 = vec2.sub(vec2.create(), p2, this.p);
+ return crossz(this.v, v2);
+ }
+
+ public intersectLine(out: vec2, l2: Line): vec2 {
+ const vp = vec2.sub(vec2.create(), l2.p, this.p);
+ const d = crossz(vp, this.v);
+ const d2 = d / crossz(this.v, l2.v);
+ return vec2.scaleAndAdd(out, l2.p, l2.v, d2);
+ }
+}
+
+export class Movement {
+ public readonly v: vec2;
+
+ constructor(
+ public readonly src: vec2,
+ public readonly dest: vec2,
+ ) {
+ this.v = vec2.sub(vec2.create(), dest, src);
+ }
+
+ public intersectLine(out: vec2, l: Line): vec2 {
+ const vp = vec2.sub(vec2.create(), l.p, this.src);
+ const d = crossz(vp, this.v);
+ const d2 = d / crossz(this.v, l.v);
+ return vec2.scaleAndAdd(out, l.p, l.v, d2);
+ }
+
+ public passes(p: vec2): boolean {
+ const vp = vec2.sub(vec2.create(), p, this.src);
+ const d = vec2.dot(this.v, vp);
+ return d >= 0 && d <= vec2.sqrLen(this.v);
+ }
+
+ public toLineSegment(): LineSegment {
+ return LineSegment.fromPoints(this.src, this.dest);
+ }
+
+ public translate(t: vec2): Movement {
+ const src = vec2.add(vec2.create(), this.src, t);
+ const dest = vec2.add(vec2.create(), this.dest, t);
+ return new Movement(src, dest);
+ }
+}
+
+export class LineSegment extends Line implements Collidable {
+ public static fromPoints(p1: vec2, p2: vec2): LineSegment {
+ const d = vec2.dist(p1, p2);
+ const v = vec2.sub(vec2.create(), p2, p1);
+ vec2.scale(v, v, 1 / d);
+
+ return new LineSegment(p1, v, d);
+ }
+
+ constructor(
+ p: vec2,
+ v: vec2,
+ public readonly l: number,
+ ) {
+ super(p, v);
+ }
+
+ public getP2(out: vec2): vec2 {
+ return vec2.scaleAndAdd(out, this.p, this.v, this.l);
+ }
+
+ public containsPoint(p2: vec2): boolean {
+ const d = this.projectPointDistance(p2);
+ return (d >= 0 && d <= this.l);
+ }
+
+ public collide(out: vec2, move: Movement, r: number): boolean {
+ if (this.distancePoint(move.src) < 0)
+ return false;
+
+ if (crossz(move.v, this.v) < 0)
+ return false;
+
+ const t = this.getNormal(vec2.create());
+ vec2.scale(t, t, -r);
+
+ const refMove = move.translate(t);
+
+ if (!this.collideRef(out, refMove))
+ return false;
+
+ vec2.sub(out, out, t);
+ return true;
+ }
+
+ private collideRef(out: vec2, move: Movement): boolean {
+ if (this.distancePoint(move.dest) >= 0)
+ return false;
+
+ const x = move.intersectLine(vec2.create(), this);
+ if (!this.containsPoint(x))
+ return false;
+
+ this.projectPoint(out, move.dest);
+
+ return true;
+ }
+}
diff --git a/src/renderer/math/point.ts b/src/renderer/math/point.ts
new file mode 100644
index 0000000..0865b8f
--- /dev/null
+++ b/src/renderer/math/point.ts
@@ -0,0 +1,40 @@
+import { Collidable } from './collision';
+import { Line, Movement, normal } from './line';
+
+import { vec2 } from 'gl-matrix';
+
+export class Point implements Collidable {
+ constructor(public readonly p: vec2) {}
+
+ public collide(out: vec2, move: Movement, r: number): boolean {
+ const moveLine = move.toLineSegment();
+
+ if (moveLine.projectPointDistance(this.p) < 0)
+ return false;
+
+ const d = moveLine.distancePoint(this.p) / r;
+ if (Math.abs(d) >= 1)
+ return false;
+
+ const e = Math.sqrt(1 - d * d);
+
+ const t = moveLine.getNormal(vec2.create());
+ vec2.scale(t, t, d);
+ vec2.scaleAndAdd(t, t, moveLine.v, e);
+
+ const tr = vec2.scale(vec2.create(), t, r);
+
+ const refMove = move.translate(tr);
+
+ if (vec2.sqrDist(this.p, move.src) > r * r && !refMove.passes(this.p))
+ return false;
+
+ normal(t, t);
+
+ const tang = new Line(this.p, t);
+ tang.projectPoint(out, refMove.dest);
+ vec2.sub(out, out, tr);
+
+ return true;
+ }
+}
diff --git a/src/renderer/model/data/collision.ts b/src/renderer/model/data/collision.ts
new file mode 100644
index 0000000..29d0879
--- /dev/null
+++ b/src/renderer/model/data/collision.ts
@@ -0,0 +1,12 @@
+export interface PolygonCollision {
+ readonly type: 'polygon';
+ readonly vertices: Array<[number, number]>;
+}
+
+export interface CircleCollision {
+ readonly type: 'circle';
+ readonly center: [number, number];
+ readonly radius: number;
+}
+
+export type Collision = PolygonCollision | CircleCollision;
diff --git a/src/renderer/model/data/entity.ts b/src/renderer/model/data/entity.ts
new file mode 100644
index 0000000..f52c130
--- /dev/null
+++ b/src/renderer/model/data/entity.ts
@@ -0,0 +1,29 @@
+import { Collision } from './collision';
+
+export interface EntityAnimation {
+ readonly sequence: ReadonlyArray<[number, number]>;
+}
+
+export interface EntityDataInput {
+ readonly sprite: string;
+ readonly anchor?: [number, number];
+ readonly collision?: Collision[];
+ readonly frames?: number;
+ readonly animation?: EntityAnimation;
+}
+
+export class EntityData {
+ public readonly sprite: string;
+ public readonly anchor: [number, number];
+ public readonly collision: Collision[];
+ public readonly frames: number;
+ public readonly animation?: EntityAnimation;
+
+ constructor(input: EntityDataInput) {
+ this.sprite = input.sprite;
+ this.anchor = input.anchor || [0.5, 0.5];
+ this.collision = input.collision || [];
+ this.frames = input.frames || 1;
+ this.animation = input.animation;
+ }
+}
diff --git a/src/renderer/model/data/map.ts b/src/renderer/model/data/map.ts
new file mode 100644
index 0000000..81ce051
--- /dev/null
+++ b/src/renderer/model/data/map.ts
@@ -0,0 +1,29 @@
+import { Collision } from './collision';
+
+export interface MapLayer {
+ readonly tiles: number[][];
+}
+
+export interface MapDataInput {
+ readonly tiles: string[];
+ readonly layers: MapLayer[];
+ readonly collision: Collision[];
+}
+
+export class MapData {
+ public readonly tiles: string[];
+ public readonly layers: MapLayer[];
+ public readonly collision: Collision[];
+
+ public readonly width: number;
+ public readonly height: number;
+
+ constructor(data: MapDataInput) {
+ this.tiles = data.tiles;
+ this.layers = data.layers;
+ this.collision = data.collision;
+
+ this.height = this.layers[0].tiles.length;
+ this.width = this.layers[0].tiles[0].length;
+ }
+}
diff --git a/src/renderer/util.ts b/src/renderer/util.ts
new file mode 100644
index 0000000..cea404b
--- /dev/null
+++ b/src/renderer/util.ts
@@ -0,0 +1,84 @@
+export function recordToMap<T>(r: Record<string, T>): Map<string, T> {
+ const ret = new Map();
+
+ for (const k of Object.keys(r))
+ ret.set(k, r[k]);
+
+ return ret;
+}
+
+export function mapValues<K, V1, V2>(f: (v: V1) => V2, map: Map<K, V1>): Map<K, V2> {
+ const ret: Map<K, V2> = new Map();
+
+ for (const [k, v] of map)
+ ret.set(k, f(v));
+
+ return ret;
+}
+
+export async function mapValuesAsync<K, V1, V2>(f: (v: V1) => Promise<V2>, map: Map<K, V1>): Promise<Map<K, V2>> {
+ const ret: Map<K, V2> = new Map();
+
+ for (const [k, v] of mapValues(f, map))
+ ret.set(k, await v);
+
+ return ret;
+}
+
+export function nextPowerOf2(n: number): number {
+ let i = 1;
+
+ while (i < n)
+ i *= 2;
+
+ return i;
+}
+
+export class Listenable<T extends any[]> {
+ private readonly listeners: Array<(...args: T) => void> = [];
+
+ public addListener(listener: (...args: T) => void): void {
+ this.listeners.push(listener);
+ }
+
+ protected runListeners(...args: T): void {
+ this.listeners.forEach((l) => l(...args));
+ }
+}
+
+export function get(url: string): Promise<XMLHttpRequest> {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+
+ const handleError = () => {
+ if (xhr.readyState !== xhr.DONE) {
+ reject(new Error('HTTP request ended in state ' + xhr.readyState));
+ return;
+ }
+
+ reject(new Error('HTTP request returned status ' + xhr.status));
+ };
+
+ xhr.addEventListener('error', handleError);
+
+ xhr.addEventListener('load', () => {
+ if (xhr.readyState !== xhr.DONE || xhr.status !== 200) {
+ handleError();
+ return;
+ }
+
+ resolve(xhr);
+ });
+
+ xhr.open('GET', url, true);
+ xhr.send();
+ });
+}
+
+export async function getJSON(url: string): Promise<any> {
+ return JSON.parse((await get(url)).responseText);
+}
+
+export function nextAnimationFrame(): Promise<DOMHighResTimeStamp> {
+ return new Promise((resolve) => window.requestAnimationFrame(resolve));
+}
diff --git a/src/renderer/view/entity.ts b/src/renderer/view/entity.ts
new file mode 100644
index 0000000..ec91503
--- /dev/null
+++ b/src/renderer/view/entity.ts
@@ -0,0 +1,72 @@
+import { EntityData } from '../model/data/entity';
+import { Renderer } from './renderer/renderer';
+import { SpriteCoords, SpriteView, SpriteViewBuilder } from './sprite';
+import { loadImage, mkTexture } from './util/image';
+
+import { getJSON } from '../util';
+
+import { vec2 } from 'gl-matrix';
+
+export class EntityView {
+ public static async load(r: Renderer, name: string): Promise<EntityView> {
+ const data = new EntityData(await getJSON(`resources/entity/${name}.json`));
+ const tile = await loadImage(`resources/sprite/entity/${data.sprite}.png`);
+
+ const [texture, size] = mkTexture(r, tile);
+ const frameSize = [size[0], size[1] / data.frames];
+
+ const offset = vec2.mul(vec2.create(), frameSize, data.anchor);
+ r.snapToGrid(offset, offset);
+
+ const coords: SpriteCoords = [
+ -offset[0],
+ -offset[1],
+ -offset[0] + frameSize[0],
+ -offset[1] + frameSize[1],
+ ];
+
+ const sprites: SpriteView[] = [];
+
+ for (let frame = 0; frame < data.frames; frame++) {
+ const builder = new SpriteViewBuilder(r, texture);
+ builder.addSprite(coords, [0, frame / data.frames, 1, (frame + 1) / data.frames]);
+ sprites.push(builder.build());
+ }
+
+ return new EntityView(
+ data,
+ sprites,
+ );
+ }
+
+ private readonly totalTime: number;
+
+ private constructor(
+ public readonly data: EntityData,
+ public readonly sprites: SpriteView[],
+ ) {
+ if (data.animation)
+ this.totalTime = data.animation.sequence.reduce((a, s) => a + s[0], 0);
+ else
+ this.totalTime = 0;
+ }
+
+ public getSpriteByTime(time: number): SpriteView {
+ time %= this.totalTime;
+
+ if (this.data.animation) {
+ for (const [len, sprite] of this.data.animation.sequence) {
+ time -= len;
+ if (time < 0)
+ return this.sprites[sprite];
+ }
+ }
+
+ return this.sprites[0];
+ }
+
+ public renderByTime(time: number) {
+ this.getSpriteByTime(time).render();
+ }
+
+}
diff --git a/src/renderer/view/input/gameinput.ts b/src/renderer/view/input/gameinput.ts
new file mode 100644
index 0000000..67fbe0c
--- /dev/null
+++ b/src/renderer/view/input/gameinput.ts
@@ -0,0 +1,78 @@
+import { InputHandler } from './inputhandler';
+
+import { Listenable } from '../../util';
+
+import { vec2 } from 'gl-matrix';
+
+export enum ButtonCode {
+ Action,
+ Back,
+ Menu,
+}
+
+const buttonMapping: Record<string, ButtonCode> = {
+ KeyZ: ButtonCode.Action,
+ KeyX: ButtonCode.Back,
+ KeyC: ButtonCode.Menu,
+};
+
+export interface DirectionInput {
+ type: 'direction';
+ direction: vec2;
+}
+
+export interface ButtonInput {
+ type: 'button';
+ button: ButtonCode;
+}
+
+export type GameInput = DirectionInput | ButtonInput;
+
+export class GameInputHandler extends Listenable<[GameInput]> {
+ private readonly input: InputHandler;
+
+ constructor() {
+ super();
+
+ this.input = new InputHandler(
+ new Set([
+ 'ArrowLeft',
+ 'ArrowUp',
+ 'ArrowRight',
+ 'ArrowDown',
+ ...Object.keys(buttonMapping),
+ ]));
+
+ this.input.addListener((key: string, pressed: boolean) => {
+ const button = buttonMapping[key];
+ if (button !== undefined) {
+ if (pressed)
+ this.runListeners({
+ type: 'button',
+ button,
+ });
+
+ return;
+ }
+
+ const dir = vec2.create();
+
+ if (this.input.has('ArrowLeft'))
+ vec2.add(dir, dir, [-1, 0]);
+ if (this.input.has('ArrowUp'))
+ vec2.add(dir, dir, [0, -1]);
+ if (this.input.has('ArrowRight'))
+ vec2.add(dir, dir, [1, 0]);
+ if (this.input.has('ArrowDown'))
+ vec2.add(dir, dir, [0, 1]);
+
+ if (vec2.sqrLen(dir) > 0)
+ vec2.normalize(dir, dir);
+
+ this.runListeners({
+ type: 'direction',
+ direction: dir,
+ });
+ });
+ }
+}
diff --git a/src/renderer/view/input/inputhandler.ts b/src/renderer/view/input/inputhandler.ts
new file mode 100644
index 0000000..17abfe6
--- /dev/null
+++ b/src/renderer/view/input/inputhandler.ts
@@ -0,0 +1,44 @@
+import { Listenable } from '../../util';
+
+export class InputHandler extends Listenable<[string, boolean]> {
+ private readonly keys: Set<string> = new Set();
+
+ constructor(relevantKeys: Set<string>) {
+ super();
+
+ window.addEventListener('keydown', (ev) => {
+ if (!relevantKeys.has(ev.code))
+ return;
+
+ ev.preventDefault();
+
+ if (ev.repeat)
+ return;
+
+ this.keys.add(ev.code);
+ this.runListeners(ev.code, true);
+ });
+
+ window.addEventListener('keyup', (ev) => {
+ if (!relevantKeys.has(ev.code))
+ return;
+
+ ev.preventDefault();
+
+ if (!this.keys.has(ev.code))
+ return;
+
+ this.keys.delete(ev.code);
+ this.runListeners(ev.code, false);
+ });
+
+ window.addEventListener('blur', () => {
+ this.keys.clear();
+ this.runListeners('', false);
+ });
+ }
+
+ public has(key: string): boolean {
+ return this.keys.has(key);
+ }
+}
diff --git a/src/renderer/view/map.ts b/src/renderer/view/map.ts
new file mode 100644
index 0000000..18def05
--- /dev/null
+++ b/src/renderer/view/map.ts
@@ -0,0 +1,173 @@
+import { EntityView } from './entity';
+import { Renderer } from './renderer/renderer';
+import { SpriteCoords, SpriteView, SpriteViewBuilder } from './sprite';
+import { loadImage, mkTexture } from './util/image';
+
+import { MapData } from '../model/data/map';
+
+import { nextPowerOf2 } from '../util';
+
+interface StaticMapTile {
+ type: 'static';
+ image: HTMLImageElement;
+}
+
+interface EntityTile {
+ type: 'entity';
+ entity: EntityView;
+}
+
+type MapTile = StaticMapTile | EntityTile;
+
+interface StaticTilesetTile {
+ type: 'static';
+ coords: SpriteCoords;
+}
+
+type TilesetTile = StaticTilesetTile | EntityTile;
+
+interface Tileset {
+ texture: WebGLTexture;
+ tiles: TilesetTile[];
+}
+
+async function loadTile(r: Renderer, tile: string): Promise<MapTile> {
+ const name = tile.substr(1);
+ switch (tile[0]) {
+ case '-':
+ return {
+ type: 'static',
+ image: await loadImage(`resources/sprite/tile/${name}.png`),
+ };
+
+ case '@':
+ return {
+ type: 'entity',
+ entity: await EntityView.load(r, name),
+ };
+
+ default:
+ throw new Error('invalid tile specifier');
+ }
+}
+
+function loadTiles(r: Renderer, tiles: string[]): Promise<MapTile[]> {
+ return Promise.all(tiles.map((tile) => loadTile(r, tile)));
+}
+
+function mkTileset(
+ r: Renderer,
+ mapTiles: MapTile[],
+): Tileset {
+ const tileSize = 32;
+
+ const canvasDim = nextPowerOf2(Math.sqrt(mapTiles.length));
+ const canvasSize = canvasDim * tileSize;
+
+ const canvas = document.createElement('canvas');
+ canvas.width = canvas.height = canvasSize;
+
+ let x = 0, y = 0;
+ const tiles: TilesetTile[] = [];
+ const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
+
+ for (const tile of mapTiles) {
+ switch (tile.type) {
+ case 'static':
+ ctx.drawImage(tile.image, x * tileSize, y * tileSize);
+ tiles.push({
+ type: 'static',
+ coords: [x / canvasDim, y / canvasDim, (x + 1) / canvasDim, (y + 1) / canvasDim],
+ });
+
+ x++;
+ if (x === canvasDim) {
+ x = 0;
+ y++;
+ }
+ break;
+
+ case 'entity':
+ tiles.push(tile);
+ break;
+ }
+ }
+
+ const [texture] = mkTexture(r, canvas);
+
+ return {
+ texture,
+ tiles,
+ };
+}
+
+function addSprite(
+ builder: SpriteViewBuilder,
+ entityTiles: Array<[[number, number], EntityView]>,
+ tileset: Tileset,
+ x: number,
+ y: number,
+ tile: number) {
+ if (!tile)
+ return;
+
+ const tilesetTile = tileset.tiles[tile - 1];
+
+ switch (tilesetTile.type) {
+ case 'static':
+ builder.addSprite([x, y, x + 1, y + 1], tilesetTile.coords);
+ break;
+
+ case 'entity':
+ entityTiles.push([[x + 0.5, y + 0.5], tilesetTile.entity]);
+ break;
+ }
+}
+
+function buildMapLayer(r: Renderer, tileset: Tileset, layer: number[][]): MapLayerView {
+ const builder = new SpriteViewBuilder(r, tileset.texture);
+ const entityTiles: Array<[[number, number], EntityView]> = [];
+
+ for (let x = 0; x < layer[0].length; x++)
+ for (let y = 0; y < layer.length; y++)
+ addSprite(builder, entityTiles, tileset, x, y, layer[y][x]);
+
+ return new MapLayerView(r, builder.build(), entityTiles);
+}
+
+class MapLayerView {
+ public constructor(
+ private r: Renderer,
+ private staticTiles: SpriteView,
+ private entityTiles: Array<[[number, number], EntityView]>,
+ ) {
+ }
+
+ public render(time: number): void {
+ this.r.setTranslation([0, 0]);
+ this.staticTiles.render();
+
+ for (const [coords, entity] of this.entityTiles) {
+ this.r.setTranslation(coords);
+ entity.renderByTime(time);
+ }
+ }
+}
+
+export class MapView {
+ public static async load(r: Renderer, map: MapData): Promise<MapView> {
+ const tiles = await loadTiles(r, map.tiles);
+ const tileset = mkTileset(r, tiles);
+
+ const layers = map.layers.map((layer) => buildMapLayer(r, tileset, layer.tiles));
+ return new MapView(layers);
+ }
+
+ private constructor(private layers: MapLayerView[]) {
+ }
+
+ public render(time: number): void {
+ for (const layer of this.layers)
+ layer.render(time);
+ }
+}
diff --git a/src/renderer/view/renderer/renderer.ts b/src/renderer/view/renderer/renderer.ts
new file mode 100644
index 0000000..93f8589
--- /dev/null
+++ b/src/renderer/view/renderer/renderer.ts
@@ -0,0 +1,99 @@
+import { Shaders } from './shaders';
+
+import { mat4, vec2 } from 'gl-matrix';
+
+export class Renderer {
+ public readonly coordScale = 32;
+ private readonly viewScale = 2;
+
+ private readonly gl: WebGLRenderingContext;
+ private readonly shaders: Shaders;
+
+ private readonly center: vec2 = vec2.create();
+ private readonly translation: vec2 = vec2.create();
+ private readonly viewport: mat4 = mat4.create();
+
+ constructor(private readonly canvas: HTMLCanvasElement) {
+ this.gl = this.mkContext();
+
+ this.shaders = new Shaders(this.gl);
+
+ this.gl.clearColor(0.0, 0.0, 0.0, 1.0);
+
+ this.gl.enable(this.gl.BLEND);
+ this.gl.blendFunc(this.gl.ONE, this.gl.ONE_MINUS_SRC_ALPHA);
+ this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
+
+ this.setSize();
+ }
+
+ public createBuffer(): WebGLBuffer {
+ const ret = this.gl.createBuffer();
+ if (!ret)
+ throw new Error('unable to create buffer');
+
+ return ret;
+ }
+
+ public getContext(): WebGLRenderingContext {
+ return this.gl;
+ }
+
+ public getVertexPosLoc(): number {
+ return this.shaders.vertexPosLoc;
+ }
+
+ public getTextureCoordLoc(): number {
+ return this.shaders.textureCoordLoc;
+ }
+
+ public getSamplerLoc(): WebGLUniformLocation {
+ return this.shaders.samplerLoc;
+ }
+
+ public setCenter(v: vec2|number[]) {
+ this.snapToGrid(this.center, v);
+ }
+
+ public setTranslation(v: vec2|number[]) {
+ vec2.sub(this.translation, v, this.center);
+ this.snapToGrid(this.translation, this.translation);
+ this.gl.uniform2fv(this.shaders.translateLoc, this.translation);
+ }
+
+ public clear(): void {
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT);
+
+ this.setTranslation([0, 0]);
+ }
+
+ public snapToGrid(out: vec2, a: vec2|number[]): void {
+ vec2.scale(out, a, this.coordScale);
+ vec2.round(out, out);
+ vec2.scale(out, out, 1 / this.coordScale);
+ }
+
+ private mkContext(): WebGLRenderingContext {
+ const gl = (
+ this.canvas.getContext('webgl') || this.canvas.getContext('experimental-webgl')
+ );
+ if (!gl)
+ throw new Error('unable to initialize WebGL context');
+
+ return gl;
+ }
+
+ private setSize(): void {
+ const w = this.canvas.width;
+ const h = this.canvas.height;
+
+ this.gl.viewport(0, 0, w, h);
+ this.clear();
+
+ const scale = this.viewScale * this.coordScale;
+
+ mat4.identity(this.viewport);
+ mat4.scale(this.viewport, this.viewport, [2 * scale / w, -2 * scale / h, 1.0]);
+ this.gl.uniformMatrix4fv(this.shaders.viewportLoc, false, this.viewport);
+ }
+}
diff --git a/src/renderer/view/renderer/shaders.ts b/src/renderer/view/renderer/shaders.ts
new file mode 100644
index 0000000..8fd1fda
--- /dev/null
+++ b/src/renderer/view/renderer/shaders.ts
@@ -0,0 +1,79 @@
+import fragmentShaderSrc from './shaders/default.fs';
+import vertexShaderSrc from './shaders/default.vs';
+
+export class Shaders {
+ public readonly viewportLoc: WebGLUniformLocation;
+ public readonly translateLoc: WebGLUniformLocation;
+
+ public readonly vertexPosLoc: number;
+ public readonly textureCoordLoc: number;
+ public readonly samplerLoc: WebGLUniformLocation;
+
+ constructor(private readonly gl: WebGLRenderingContext) {
+ const shaderProgram = this.gl.createProgram();
+ if (!shaderProgram)
+ throw new Error('Unable to create shader program');
+
+ const vertexShader = this.compileShader(this.gl.VERTEX_SHADER, vertexShaderSrc);
+ const fragmentShader = this.compileShader(this.gl.FRAGMENT_SHADER, fragmentShaderSrc);
+
+ this.gl.attachShader(shaderProgram, vertexShader);
+ this.gl.attachShader(shaderProgram, fragmentShader);
+
+ this.gl.linkProgram(shaderProgram);
+ if (!this.gl.getProgramParameter(shaderProgram, this.gl.LINK_STATUS)) {
+ const err = this.gl.getProgramInfoLog(shaderProgram);
+
+ this.gl.deleteShader(vertexShader);
+ this.gl.deleteShader(fragmentShader);
+ this.gl.deleteProgram(shaderProgram);
+
+ throw new Error('Unable to link shader: ' + err);
+ }
+
+ this.gl.useProgram(shaderProgram);
+
+ this.vertexPosLoc = this.getAttribLocation(shaderProgram, 'aVertexPos');
+ this.gl.enableVertexAttribArray(this.vertexPosLoc);
+
+ this.textureCoordLoc = this.getAttribLocation(shaderProgram, 'aTextureCoord');
+ this.gl.enableVertexAttribArray(this.textureCoordLoc);
+
+ this.viewportLoc = this.getUniformLocation(shaderProgram, 'uViewport');
+ this.translateLoc = this.getUniformLocation(shaderProgram, 'uTranslate');
+ this.samplerLoc = this.getUniformLocation(shaderProgram, 'uSampler');
+ }
+
+ private compileShader(type: number, src: string): WebGLShader {
+ const shader = this.gl.createShader(type);
+ if (!shader)
+ throw new Error('Unable to create shader');
+
+ this.gl.shaderSource(shader, src);
+ this.gl.compileShader(shader);
+
+ if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
+ const err = this.gl.getShaderInfoLog(shader);
+ this.gl.deleteShader(shader);
+ throw new Error('Unable to compile shader: ' + err);
+ }
+
+ return shader;
+ }
+
+ private getAttribLocation(program: WebGLProgram, name: string): number {
+ const ret = this.gl.getAttribLocation(program, name);
+ if (ret < 0)
+ throw new Error("unable to get location of attribute '" + name + "'");
+
+ return ret;
+ }
+
+ private getUniformLocation(program: WebGLProgram, name: string): WebGLUniformLocation {
+ const ret = this.gl.getUniformLocation(program, name);
+ if (!ret)
+ throw new Error("unable to get location of uniform '" + name + "'");
+
+ return ret;
+ }
+}
diff --git a/src/renderer/view/renderer/shaders/default.fs b/src/renderer/view/renderer/shaders/default.fs
new file mode 100644
index 0000000..2c8ad15
--- /dev/null
+++ b/src/renderer/view/renderer/shaders/default.fs
@@ -0,0 +1,10 @@
+precision highp float;
+
+varying vec2 vTextureCoord;
+
+uniform sampler2D uSampler;
+
+
+void main(void) {
+ gl_FragColor = texture2D(uSampler, vTextureCoord);
+}
diff --git a/src/renderer/view/renderer/shaders/default.vs b/src/renderer/view/renderer/shaders/default.vs
new file mode 100644
index 0000000..4715a17
--- /dev/null
+++ b/src/renderer/view/renderer/shaders/default.vs
@@ -0,0 +1,13 @@
+attribute vec2 aVertexPos;
+attribute vec2 aTextureCoord;
+
+uniform mat4 uViewport;
+uniform vec2 uTranslate;
+
+varying highp vec2 vTextureCoord;
+
+
+void main(void) {
+ gl_Position = uViewport * vec4(aVertexPos + uTranslate, 0.0, 1.0);
+ vTextureCoord = aTextureCoord;
+}
diff --git a/src/renderer/view/sprite.ts b/src/renderer/view/sprite.ts
new file mode 100644
index 0000000..3a5ebcb
--- /dev/null
+++ b/src/renderer/view/sprite.ts
@@ -0,0 +1,72 @@
+import { Renderer } from './renderer/renderer';
+
+export type SpriteCoords = [number, number, number, number];
+
+export class SpriteViewBuilder {
+ private static pushSprite(buf: number[], coords: SpriteCoords): void {
+ const [x1, y1, x2, y2] = coords;
+
+ buf.push(x1); buf.push(y1);
+ buf.push(x2); buf.push(y1);
+ buf.push(x1); buf.push(y2);
+
+ buf.push(x1); buf.push(y2);
+ buf.push(x2); buf.push(y1);
+ buf.push(x2); buf.push(y2);
+ }
+
+ private readonly vertexData: number[] = [];
+ private readonly textureData: number[] = [];
+
+ constructor(private readonly r: Renderer, private readonly texture: WebGLTexture) {}
+
+ public addSprite(vertexCoords: SpriteCoords, texCoords: SpriteCoords): void {
+ SpriteViewBuilder.pushSprite(this.vertexData, vertexCoords);
+ SpriteViewBuilder.pushSprite(this.textureData, texCoords);
+ }
+
+ public build(): SpriteView {
+ return new SpriteView(this.r, this.texture, this.vertexData, this.textureData);
+ }
+}
+
+export class SpriteView {
+ private readonly vertexCount: number;
+ private readonly vertexBuffer: WebGLBuffer;
+ private readonly textureBuffer: WebGLBuffer;
+
+ constructor(
+ private readonly r: Renderer,
+ private readonly texture: WebGLTexture,
+ vertexData: number[],
+ textureData: number[],
+ ) {
+ const gl = r.getContext();
+
+ this.vertexBuffer = r.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertexData), gl.STATIC_DRAW);
+
+ this.textureBuffer = r.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textureData), gl.STATIC_DRAW);
+
+ this.vertexCount = vertexData.length / 2;
+ }
+
+ public render(): void {
+ const gl = this.r.getContext();
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.uniform1i(this.r.getSamplerLoc(), 0);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
+ gl.vertexAttribPointer(this.r.getVertexPosLoc(), 2, gl.FLOAT, false, 0, 0);
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
+ gl.vertexAttribPointer(this.r.getTextureCoordLoc(), 2, gl.FLOAT, false, 0, 0);
+
+ gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount);
+ }
+}
diff --git a/src/renderer/view/util/image.ts b/src/renderer/view/util/image.ts
new file mode 100644
index 0000000..37c5e7f
--- /dev/null
+++ b/src/renderer/view/util/image.ts
@@ -0,0 +1,33 @@
+import { Renderer } from '../renderer/renderer';
+
+export function loadImage(url: string): Promise<HTMLImageElement> {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.addEventListener('load', () => { resolve(img); });
+ img.addEventListener('error', () => { reject(new Error('failed to load ' + url)); });
+ img.src = url;
+ });
+}
+
+export function mkTexture(
+ r: Renderer,
+ src: HTMLCanvasElement|HTMLImageElement,
+): [WebGLTexture, [number, number]] {
+ const gl = r.getContext();
+ const texture = gl.createTexture();
+ if (!texture)
+ throw new Error('unable to create texture');
+
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+
+ const size: [number, number] = [
+ src.width / r.coordScale, src.height / r.coordScale,
+ ];
+
+ return [texture, size];
+}