diff options
Diffstat (limited to 'src/renderer/runtime')
22 files changed, 1304 insertions, 0 deletions
diff --git a/src/renderer/runtime/controller/collision.ts b/src/renderer/runtime/controller/collision.ts new file mode 100644 index 0000000..a44ffa4 --- /dev/null +++ b/src/renderer/runtime/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/runtime/controller/entitycontext.ts b/src/renderer/runtime/controller/entitycontext.ts new file mode 100644 index 0000000..434bf9b --- /dev/null +++ b/src/renderer/runtime/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/runtime/controller/gamecontext.ts b/src/renderer/runtime/controller/gamecontext.ts new file mode 100644 index 0000000..9a68e25 --- /dev/null +++ b/src/renderer/runtime/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/runtime/index.css b/src/renderer/runtime/index.css new file mode 100644 index 0000000..672c410 --- /dev/null +++ b/src/renderer/runtime/index.css @@ -0,0 +1,12 @@ +html, body, div { + width: 100%; + height: 100%; +} + +* { + margin: 0px; + padding: 0px; + border: 0px; + background: #223; + overflow: hidden; +} diff --git a/src/renderer/runtime/math/circle.ts b/src/renderer/runtime/math/circle.ts new file mode 100644 index 0000000..f4e855a --- /dev/null +++ b/src/renderer/runtime/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/runtime/math/collision.ts b/src/renderer/runtime/math/collision.ts new file mode 100644 index 0000000..3df811a --- /dev/null +++ b/src/renderer/runtime/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/runtime/math/line.ts b/src/renderer/runtime/math/line.ts new file mode 100644 index 0000000..db99035 --- /dev/null +++ b/src/renderer/runtime/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/runtime/math/point.ts b/src/renderer/runtime/math/point.ts new file mode 100644 index 0000000..0865b8f --- /dev/null +++ b/src/renderer/runtime/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/runtime/model/data/collision.ts b/src/renderer/runtime/model/data/collision.ts new file mode 100644 index 0000000..29d0879 --- /dev/null +++ b/src/renderer/runtime/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/runtime/model/data/entity.ts b/src/renderer/runtime/model/data/entity.ts new file mode 100644 index 0000000..f52c130 --- /dev/null +++ b/src/renderer/runtime/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/runtime/model/data/map.ts b/src/renderer/runtime/model/data/map.ts new file mode 100644 index 0000000..81ce051 --- /dev/null +++ b/src/renderer/runtime/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/runtime/util.ts b/src/renderer/runtime/util.ts new file mode 100644 index 0000000..5ea0c88 --- /dev/null +++ b/src/renderer/runtime/util.ts @@ -0,0 +1,59 @@ +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 async function getJSON(url: string): Promise<any> { + const res = await window.fetch(url); + if (res.status < 200 || res.status >= 300) + throw new Error(res.statusText); + + return await res.json(); +} + +export function nextAnimationFrame(): Promise<DOMHighResTimeStamp> { + return new Promise((resolve) => window.requestAnimationFrame(resolve)); +} diff --git a/src/renderer/runtime/view/entity.ts b/src/renderer/runtime/view/entity.ts new file mode 100644 index 0000000..ec91503 --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/input/gameinput.ts b/src/renderer/runtime/view/input/gameinput.ts new file mode 100644 index 0000000..67fbe0c --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/input/inputhandler.ts b/src/renderer/runtime/view/input/inputhandler.ts new file mode 100644 index 0000000..17abfe6 --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/map.ts b/src/renderer/runtime/view/map.ts new file mode 100644 index 0000000..18def05 --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/renderer/renderer.ts b/src/renderer/runtime/view/renderer/renderer.ts new file mode 100644 index 0000000..9f711d3 --- /dev/null +++ b/src/renderer/runtime/view/renderer/renderer.ts @@ -0,0 +1,100 @@ +import { nextPowerOf2 } from '../../util'; +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); + } + + 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); + } + + public resize(): void { + const w = this.canvas.width; + const h = this.canvas.height; + const ws = nextPowerOf2(w); + const hs = nextPowerOf2(h); + + this.gl.viewport((w - ws) / 2, (h - hs) / 2, ws, hs); + this.clear(); + + const scale = this.viewScale * this.coordScale; + + mat4.identity(this.viewport); + mat4.scale(this.viewport, this.viewport, [2 * scale / ws, -2 * scale / hs, 1.0]); + this.gl.uniformMatrix4fv(this.shaders.viewportLoc, false, this.viewport); + } + + 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; + } +} diff --git a/src/renderer/runtime/view/renderer/shaders.ts b/src/renderer/runtime/view/renderer/shaders.ts new file mode 100644 index 0000000..8fd1fda --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/renderer/shaders/default.fs b/src/renderer/runtime/view/renderer/shaders/default.fs new file mode 100644 index 0000000..2c8ad15 --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/renderer/shaders/default.vs b/src/renderer/runtime/view/renderer/shaders/default.vs new file mode 100644 index 0000000..4715a17 --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/sprite.ts b/src/renderer/runtime/view/sprite.ts new file mode 100644 index 0000000..3a5ebcb --- /dev/null +++ b/src/renderer/runtime/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/runtime/view/util/image.ts b/src/renderer/runtime/view/util/image.ts new file mode 100644 index 0000000..37c5e7f --- /dev/null +++ b/src/renderer/runtime/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]; +} |