From b3950330e3351437f153c6c1debb3821d6e28864 Mon Sep 17 00:00:00 2001 From: Matthias Schiffer Date: Sat, 8 Dec 2018 12:39:18 +0100 Subject: Create Electron app --- src/controller/collision.ts | 65 ---------- src/controller/entitycontext.ts | 47 ------- src/controller/gamecontext.ts | 174 ------------------------- src/index.ts | 13 -- src/main/index.ts | 51 ++++++++ src/math/circle.ts | 15 --- src/math/collision.ts | 7 -- src/math/line.ts | 141 --------------------- src/math/point.ts | 40 ------ src/model/data/collision.ts | 12 -- src/model/data/entity.ts | 29 ----- src/model/data/map.ts | 29 ----- src/renderer/controller/collision.ts | 65 ++++++++++ src/renderer/controller/entitycontext.ts | 47 +++++++ src/renderer/controller/gamecontext.ts | 174 +++++++++++++++++++++++++ src/renderer/custom.d.ts | 9 ++ src/renderer/index.css | 20 +++ src/renderer/index.ts | 21 ++++ src/renderer/math/circle.ts | 15 +++ src/renderer/math/collision.ts | 7 ++ src/renderer/math/line.ts | 141 +++++++++++++++++++++ src/renderer/math/point.ts | 40 ++++++ src/renderer/model/data/collision.ts | 12 ++ src/renderer/model/data/entity.ts | 29 +++++ src/renderer/model/data/map.ts | 29 +++++ src/renderer/util.ts | 84 +++++++++++++ src/renderer/view/entity.ts | 72 +++++++++++ src/renderer/view/input/gameinput.ts | 78 ++++++++++++ src/renderer/view/input/inputhandler.ts | 44 +++++++ src/renderer/view/map.ts | 173 +++++++++++++++++++++++++ src/renderer/view/renderer/renderer.ts | 99 +++++++++++++++ src/renderer/view/renderer/shaders.ts | 79 ++++++++++++ src/renderer/view/renderer/shaders/default.fs | 10 ++ src/renderer/view/renderer/shaders/default.vs | 13 ++ src/renderer/view/sprite.ts | 72 +++++++++++ src/renderer/view/util/image.ts | 33 +++++ src/util.ts | 84 ------------- src/view/entity.ts | 72 ----------- src/view/input/gameinput.ts | 78 ------------ src/view/input/inputhandler.ts | 44 ------- src/view/map.ts | 175 -------------------------- src/view/renderer/renderer.ts | 99 --------------- src/view/renderer/shaders.ts | 79 ------------ src/view/renderer/shaders/default.fs | 10 -- src/view/renderer/shaders/default.vs | 13 -- src/view/sprite.ts | 72 ----------- src/view/util/image.ts | 34 ----- 47 files changed, 1417 insertions(+), 1332 deletions(-) delete mode 100644 src/controller/collision.ts delete mode 100644 src/controller/entitycontext.ts delete mode 100644 src/controller/gamecontext.ts delete mode 100644 src/index.ts create mode 100644 src/main/index.ts delete mode 100644 src/math/circle.ts delete mode 100644 src/math/collision.ts delete mode 100644 src/math/line.ts delete mode 100644 src/math/point.ts delete mode 100644 src/model/data/collision.ts delete mode 100644 src/model/data/entity.ts delete mode 100644 src/model/data/map.ts create mode 100644 src/renderer/controller/collision.ts create mode 100644 src/renderer/controller/entitycontext.ts create mode 100644 src/renderer/controller/gamecontext.ts create mode 100644 src/renderer/custom.d.ts create mode 100644 src/renderer/index.css create mode 100644 src/renderer/index.ts create mode 100644 src/renderer/math/circle.ts create mode 100644 src/renderer/math/collision.ts create mode 100644 src/renderer/math/line.ts create mode 100644 src/renderer/math/point.ts create mode 100644 src/renderer/model/data/collision.ts create mode 100644 src/renderer/model/data/entity.ts create mode 100644 src/renderer/model/data/map.ts create mode 100644 src/renderer/util.ts create mode 100644 src/renderer/view/entity.ts create mode 100644 src/renderer/view/input/gameinput.ts create mode 100644 src/renderer/view/input/inputhandler.ts create mode 100644 src/renderer/view/map.ts create mode 100644 src/renderer/view/renderer/renderer.ts create mode 100644 src/renderer/view/renderer/shaders.ts create mode 100644 src/renderer/view/renderer/shaders/default.fs create mode 100644 src/renderer/view/renderer/shaders/default.vs create mode 100644 src/renderer/view/sprite.ts create mode 100644 src/renderer/view/util/image.ts delete mode 100644 src/util.ts delete mode 100644 src/view/entity.ts delete mode 100644 src/view/input/gameinput.ts delete mode 100644 src/view/input/inputhandler.ts delete mode 100644 src/view/map.ts delete mode 100644 src/view/renderer/renderer.ts delete mode 100644 src/view/renderer/shaders.ts delete mode 100644 src/view/renderer/shaders/default.fs delete mode 100644 src/view/renderer/shaders/default.vs delete mode 100644 src/view/sprite.ts delete mode 100644 src/view/util/image.ts (limited to 'src') diff --git a/src/controller/collision.ts b/src/controller/collision.ts deleted file mode 100644 index a44ffa4..0000000 --- a/src/controller/collision.ts +++ /dev/null @@ -1,65 +0,0 @@ -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/controller/entitycontext.ts b/src/controller/entitycontext.ts deleted file mode 100644 index 434bf9b..0000000 --- a/src/controller/entitycontext.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 { - 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/controller/gamecontext.ts b/src/controller/gamecontext.ts deleted file mode 100644 index 9a68e25..0000000 --- a/src/controller/gamecontext.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 { - 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 { - while (true) { - this.update(await nextAnimationFrame() - this.initTime); - this.render(); - } - } -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 4db13d5..0000000 --- a/src/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GameContext } from './controller/gamecontext'; - -import { Renderer } from './view/renderer/renderer'; - -window.onload = async () => { - const canvas = document.getElementById('rpgedit') as HTMLCanvasElement; - if (!canvas) - return; - - const renderer = new Renderer(canvas); - - GameContext.load(renderer); -}; diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..a4beb71 --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,51 @@ +import { app, BrowserWindow } from 'electron'; +import * as path from 'path'; +import { format as formatUrl } from 'url'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +let mainWindow: BrowserWindow|null = null; + +function getIndexURL(): string { + if (isDevelopment) + return `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}`; + + return formatUrl({ + pathname: path.join(__dirname, 'index.html'), + protocol: 'file', + slashes: true, + }); +} + +function createMainWindow(): BrowserWindow { + const window = new BrowserWindow(); + + if (isDevelopment) + window.webContents.openDevTools(); + + window.loadURL(getIndexURL()); + + window.on('closed', () => { + mainWindow = null; + }); + + window.webContents.on('devtools-opened', () => { + window.webContents.focus(); + }); + + return window; +} + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') + app.quit(); +}); + +app.on('activate', () => { + if (!mainWindow) + mainWindow = createMainWindow(); +}); + +app.on('ready', () => { + mainWindow = createMainWindow(); +}); diff --git a/src/math/circle.ts b/src/math/circle.ts deleted file mode 100644 index f4e855a..0000000 --- a/src/math/circle.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/math/collision.ts b/src/math/collision.ts deleted file mode 100644 index 3df811a..0000000 --- a/src/math/collision.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Movement } from './line'; - -import { vec2 } from 'gl-matrix'; - -export interface Collidable { - collide(out: vec2, move: Movement, r: number): boolean; -} diff --git a/src/math/line.ts b/src/math/line.ts deleted file mode 100644 index db99035..0000000 --- a/src/math/line.ts +++ /dev/null @@ -1,141 +0,0 @@ -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/math/point.ts b/src/math/point.ts deleted file mode 100644 index 0865b8f..0000000 --- a/src/math/point.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/model/data/collision.ts b/src/model/data/collision.ts deleted file mode 100644 index 29d0879..0000000 --- a/src/model/data/collision.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/model/data/entity.ts b/src/model/data/entity.ts deleted file mode 100644 index f52c130..0000000 --- a/src/model/data/entity.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/model/data/map.ts b/src/model/data/map.ts deleted file mode 100644 index 81ce051..0000000 --- a/src/model/data/map.ts +++ /dev/null @@ -1,29 +0,0 @@ -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/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 { + 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 { + 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 { + 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(r: Record): Map { + const ret = new Map(); + + for (const k of Object.keys(r)) + ret.set(k, r[k]); + + return ret; +} + +export function mapValues(f: (v: V1) => V2, map: Map): Map { + const ret: Map = new Map(); + + for (const [k, v] of map) + ret.set(k, f(v)); + + return ret; +} + +export async function mapValuesAsync(f: (v: V1) => Promise, map: Map): Promise> { + const ret: Map = 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 { + 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 { + 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 { + return JSON.parse((await get(url)).responseText); +} + +export function nextAnimationFrame(): Promise { + 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 { + 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 = { + 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 = new Set(); + + constructor(relevantKeys: Set) { + 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 { + 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 { + 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 { + 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 { + 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]; +} diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index cea404b..0000000 --- a/src/util.ts +++ /dev/null @@ -1,84 +0,0 @@ -export function recordToMap(r: Record): Map { - const ret = new Map(); - - for (const k of Object.keys(r)) - ret.set(k, r[k]); - - return ret; -} - -export function mapValues(f: (v: V1) => V2, map: Map): Map { - const ret: Map = new Map(); - - for (const [k, v] of map) - ret.set(k, f(v)); - - return ret; -} - -export async function mapValuesAsync(f: (v: V1) => Promise, map: Map): Promise> { - const ret: Map = 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 { - 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 { - 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 { - return JSON.parse((await get(url)).responseText); -} - -export function nextAnimationFrame(): Promise { - return new Promise((resolve) => window.requestAnimationFrame(resolve)); -} diff --git a/src/view/entity.ts b/src/view/entity.ts deleted file mode 100644 index ec91503..0000000 --- a/src/view/entity.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 { - 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/view/input/gameinput.ts b/src/view/input/gameinput.ts deleted file mode 100644 index 67fbe0c..0000000 --- a/src/view/input/gameinput.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { InputHandler } from './inputhandler'; - -import { Listenable } from '../../util'; - -import { vec2 } from 'gl-matrix'; - -export enum ButtonCode { - Action, - Back, - Menu, -} - -const buttonMapping: Record = { - 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/view/input/inputhandler.ts b/src/view/input/inputhandler.ts deleted file mode 100644 index 17abfe6..0000000 --- a/src/view/input/inputhandler.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Listenable } from '../../util'; - -export class InputHandler extends Listenable<[string, boolean]> { - private readonly keys: Set = new Set(); - - constructor(relevantKeys: Set) { - 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/view/map.ts b/src/view/map.ts deleted file mode 100644 index 5eedaca..0000000 --- a/src/view/map.ts +++ /dev/null @@ -1,175 +0,0 @@ -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'; - -import { vec2 } from 'gl-matrix'; - -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 { - 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 { - 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 { - 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/view/renderer/renderer.ts b/src/view/renderer/renderer.ts deleted file mode 100644 index 93f8589..0000000 --- a/src/view/renderer/renderer.ts +++ /dev/null @@ -1,99 +0,0 @@ -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/view/renderer/shaders.ts b/src/view/renderer/shaders.ts deleted file mode 100644 index 8fd1fda..0000000 --- a/src/view/renderer/shaders.ts +++ /dev/null @@ -1,79 +0,0 @@ -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/view/renderer/shaders/default.fs b/src/view/renderer/shaders/default.fs deleted file mode 100644 index 2c8ad15..0000000 --- a/src/view/renderer/shaders/default.fs +++ /dev/null @@ -1,10 +0,0 @@ -precision highp float; - -varying vec2 vTextureCoord; - -uniform sampler2D uSampler; - - -void main(void) { - gl_FragColor = texture2D(uSampler, vTextureCoord); -} diff --git a/src/view/renderer/shaders/default.vs b/src/view/renderer/shaders/default.vs deleted file mode 100644 index 4715a17..0000000 --- a/src/view/renderer/shaders/default.vs +++ /dev/null @@ -1,13 +0,0 @@ -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/view/sprite.ts b/src/view/sprite.ts deleted file mode 100644 index 3a5ebcb..0000000 --- a/src/view/sprite.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/view/util/image.ts b/src/view/util/image.ts deleted file mode 100644 index 0ad5d16..0000000 --- a/src/view/util/image.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Renderer } from '../renderer/renderer'; -import { SpriteCoords } from '../sprite'; - -export function loadImage(url: string): Promise { - 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]; -} -- cgit v1.2.3