diff options
Diffstat (limited to 'src/renderer/runtime/view')
-rw-r--r-- | src/renderer/runtime/view/entity.ts | 72 | ||||
-rw-r--r-- | src/renderer/runtime/view/input/gameinput.ts | 78 | ||||
-rw-r--r-- | src/renderer/runtime/view/input/inputhandler.ts | 44 | ||||
-rw-r--r-- | src/renderer/runtime/view/map.ts | 173 | ||||
-rw-r--r-- | src/renderer/runtime/view/renderer/renderer.ts | 100 | ||||
-rw-r--r-- | src/renderer/runtime/view/renderer/shaders.ts | 79 | ||||
-rw-r--r-- | src/renderer/runtime/view/renderer/shaders/default.fs | 10 | ||||
-rw-r--r-- | src/renderer/runtime/view/renderer/shaders/default.vs | 13 | ||||
-rw-r--r-- | src/renderer/runtime/view/sprite.ts | 72 | ||||
-rw-r--r-- | src/renderer/runtime/view/util/image.ts | 33 |
10 files changed, 674 insertions, 0 deletions
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]; +} |