import { Game } from "planetwars-rs"; import type { Dictionary } from './webgl/util'; import type { BBox } from "./voronoi/voronoi-core"; import { Resizer, resizeCanvasToDisplaySize, FPSCounter, } from "./webgl/util"; import { Shader, Uniform4f, Uniform3fv, Uniform1f, Uniform2f, ShaderFactory, Uniform3f, UniformMatrix3fv, UniformBool, } from "./webgl/shader"; import { DefaultRenderable, Renderer } from "./webgl/renderer"; import { VertexBuffer, IndexBuffer } from "./webgl/buffer"; import { VertexBufferLayout, VertexArray } from "./webgl/vertexBufferLayout"; import { defaultLabelFactory, LabelFactory, Align, Label } from "./webgl/text"; import { VoronoiBuilder } from "./voronoi/voronoi"; import * as assets from "./assets"; import { Texture } from "./webgl/texture"; function to_bbox(box: number[]): BBox { return { xl: box[0], xr: box[0] + box[2], yt: box[1], yb: box[1] + box[3], }; } export function set_game_name(name: string) { ELEMENTS["name"].innerHTML = name; } export function set_loading(loading: boolean) { if (loading) { if (!ELEMENTS["main"].classList.contains("loading")) { ELEMENTS["main"].classList.add("loading"); } } else { ELEMENTS["main"].classList.remove("loading"); } } const ELEMENTS: any = {}; var CANVAS: any; var RESOLUTION: any; var GL: any; var ms_per_frame: any; const LAYERS = { vor: -1, // Background planet: 1, planet_label: 2, ship: 3, ship_label: 4, }; const COUNTER = new FPSCounter(); export function init() { [ "name", "turnCounter", "main", "turnSlider", "fileselect", "speed", "canvas", ].forEach((n) => (ELEMENTS[n] = document.getElementById(n))); CANVAS = ELEMENTS["canvas"]; RESOLUTION = [CANVAS.width, CANVAS.height]; ms_per_frame = parseInt(ELEMENTS["speed"].value); GL = CANVAS.getContext("webgl"); GL.clearColor(0, 0, 0, 1); GL.clear(GL.COLOR_BUFFER_BIT); GL.enable(GL.BLEND); GL.blendFunc(GL.SRC_ALPHA, GL.ONE_MINUS_SRC_ALPHA); window.addEventListener( "resize", function () { resizeCanvasToDisplaySize(CANVAS); if (game_instance) { game_instance.on_resize(); } }, { capture: false, passive: true } ); ELEMENTS["turnSlider"].oninput = function () { if (game_instance) { game_instance.updateTurn(parseInt(ELEMENTS["turnSlider"].value)); } }; ELEMENTS["speed"].onchange = function () { ms_per_frame = parseInt(ELEMENTS["speed"].value); }; } export class GameInstance { resizer: Resizer; game: Game; shader: Shader; vor_shader: Shader; image_shader: Shader; masked_image_shader: Shader; text_factory: LabelFactory; planet_labels: Label[]; ship_labels: Label[]; ship_ibo: IndexBuffer; ship_vao: VertexArray; ship_texture: Texture; // TODO: find a better way max_num_ships: number; renderer: Renderer; planet_count: number; vor_builder: VoronoiBuilder; vor_counter = 3; use_vor = true; playing = true; time_stopped_delta = 0; last_time = 0; frame = -1; turn_count = 0; constructor( game: Game, planets_textures: Texture[], ship_texture: Texture, font_texture: Texture, shaders: Dictionary ) { this.game = game; const planets = game.get_planets(); this.planet_count = planets.length; this.shader = shaders["normal"].create_shader(GL, { MAX_CIRCLES: "" + planets.length, }); this.image_shader = shaders["image"].create_shader(GL); this.vor_shader = shaders["vor"].create_shader(GL, { PLANETS: "" + planets.length, }); this.masked_image_shader = shaders["masked_image"].create_shader(GL); this.text_factory = defaultLabelFactory(GL, font_texture, this.image_shader); this.planet_labels = []; this.ship_labels = []; this.ship_texture = ship_texture this.resizer = new Resizer(CANVAS, [...game.get_viewbox()], true); this.renderer = new Renderer(); this.game.update_turn(0); // Setup key handling document.addEventListener("keydown", this.handleKey.bind(this)); // List of [(x, y, r)] for all planets this._create_voronoi(planets); this._create_planets(planets, planets_textures); this.max_num_ships = 0; // Set slider correctly this.turn_count = game.turn_count(); ELEMENTS["turnSlider"].max = this.turn_count - 1 + ""; } push_state(state: string) { this.game.push_state(state); if (this.frame == this.turn_count - 1) { this.playing = true; } // Set slider correctly this.turn_count = this.game.turn_count(); this.updateTurnCounters(); } _create_voronoi(planets: Float32Array) { const planet_points = []; for (let i = 0; i < planets.length; i += 3) { planet_points.push({ x: -planets[i], y: -planets[i + 1] }); } const bbox = to_bbox(this.resizer.get_viewbox()); this.vor_builder = new VoronoiBuilder( GL, this.vor_shader, planet_points, bbox ); this.renderer.addRenderable(this.vor_builder.getRenderable(), LAYERS.vor); } _create_planets(planets: Float32Array, planets_textures: Texture[]) { for (let i = 0; i < this.planet_count; i++) { { const transform = new UniformMatrix3fv([ 1, 0, 0, 0, 1, 0, -planets[i * 3], -planets[i * 3 + 1], 1, // TODO: why are negations needed? ]); const gl = GL; const ib = new IndexBuffer(gl, [ 0, 1, 2, 1, 2, 3 ]); const vb_pos = new VertexBuffer(gl, [ -1, 1, 1, 1, -1, -1, 1, -1 ]); const vb_tex = new VertexBuffer(gl, [ 0, 0, 1, 0, 0, 1, 1, 1]); const layout_pos = new VertexBufferLayout(); // 2? layout_pos.push(gl.FLOAT, 2, 4, "a_position"); const layout_tex = new VertexBufferLayout(); layout_tex.push(gl.FLOAT, 2, 4, "a_texCoord"); const vao = new VertexArray(); vao.addBuffer(vb_pos, layout_pos); vao.addBuffer(vb_tex, layout_tex); const uniforms = { u_trans: transform, u_trans_next: transform, }; const renderable = new DefaultRenderable(ib, vao, this.masked_image_shader, [planets_textures[0]], uniforms); this.renderer.addRenderable(renderable, LAYERS.planet); } { const transform = new UniformMatrix3fv([ 1, 0, 0, 0, 1, 0, -planets[i * 3], -planets[i * 3 + 1] - 1.2, 1, ]); const label = this.text_factory.build(GL, transform); this.planet_labels.push(label); this.renderer.addRenderable(label.getRenderable(), LAYERS.planet_label); } } } on_resize() { this.resizer = new Resizer(CANVAS, [...this.game.get_viewbox()], true); const bbox = to_bbox(this.resizer.get_viewbox()); this.vor_builder.resize(GL, bbox); } _update_state() { this._update_planets(); this._update_ships(); } _update_planets() { const colours = this.game.get_planet_colors(); const planet_ships = this.game.get_planet_ships(); this.vor_shader.uniform(GL, "u_planet_colours", new Uniform3fv(colours)); for (let i = 0; i < this.planet_count; i++) { const u = new Uniform3f( colours[i * 6], colours[i * 6 + 1], colours[i * 6 + 2] ); this.renderer.updateUniform( i, (us) => (us["u_color"] = u), LAYERS.planet ); const u2 = new Uniform3f( colours[i * 6 + 3], colours[i * 6 + 4], colours[i * 6 + 5] ); this.renderer.updateUniform( i, (us) => (us["u_color_next"] = u2), LAYERS.planet ); this.planet_labels[i].setText( GL, "*" + planet_ships[i], Align.Middle, Align.Begin ); } } _update_ships() { const ships = this.game.get_ship_locations(); const labels = this.game.get_ship_label_locations(); const ship_counts = this.game.get_ship_counts(); const ship_colours = this.game.get_ship_colours(); for (let i = this.max_num_ships; i < ship_counts.length; i++) { const gl = GL; const ib = new IndexBuffer(gl, [ 0, 1, 2, 1, 2, 3 ]); const ratio = this.ship_texture.getWidth() / this.ship_texture.getHeight(); const vb_pos = new VertexBuffer(gl, [ -ratio, 1, ratio, 1, -ratio, -1, ratio, -1 ]); const vb_tex = new VertexBuffer(gl, [ 0, 0, 1, 0, 0, 1, 1, 1, ]); const layout_pos = new VertexBufferLayout(); layout_pos.push(gl.FLOAT, 2, 4, "a_position"); const layout_tex = new VertexBufferLayout(); layout_tex.push(gl.FLOAT, 2, 4, "a_texCoord"); const vao = new VertexArray(); vao.addBuffer(vb_pos, layout_pos); vao.addBuffer(vb_tex, layout_tex); const renderable = new DefaultRenderable(ib, vao, this.masked_image_shader, [this.ship_texture], {}); this.renderer.addRenderable(renderable, LAYERS.ship); const label = this.text_factory.build(GL); this.ship_labels.push(label); this.renderer.addRenderable(label.getRenderable(), LAYERS.ship_label); } if (ship_counts.length > this.max_num_ships) this.max_num_ships = ship_counts.length; // TODO: actually remove obsolete ships for (let i = 0; i < this.max_num_ships; i++) { if (i < ship_counts.length) { this.ship_labels[i].setText( GL, "" + ship_counts[i], Align.Middle, Align.Middle ); this.renderer.enableRenderable(i, LAYERS.ship); this.renderer.enableRenderable(i, LAYERS.ship_label); const u = new Uniform3f( ship_colours[i * 3], ship_colours[i * 3 + 1], ship_colours[i * 3 + 2] ); const t1 = new UniformMatrix3fv(ships.slice(i * 18, i * 18 + 9)); const t2 = new UniformMatrix3fv(ships.slice(i * 18 + 9, i * 18 + 18)); const tl1 = new UniformMatrix3fv(labels.slice(i * 18, i * 18 + 9)); const tl2 = new UniformMatrix3fv(labels.slice(i * 18 + 9, i * 18 + 18)); this.renderer.updateUniform( i, (us) => { us["u_color"] = u; us["u_color_next"] = u; us["u_trans"] = t1; us["u_trans_next"] = t2; }, LAYERS.ship ); this.renderer.updateUniform( i, (us) => { us["u_trans"] = tl1; us["u_trans_next"] = tl2; }, LAYERS.ship_label ); } else { this.renderer.disableRenderable(i, LAYERS.ship); this.renderer.disableRenderable(i, LAYERS.ship_label); } } } render(time: number) { COUNTER.frame(time); if (COUNTER.delta(time) < 30) { this.vor_counter = Math.min(3, this.vor_counter + 1); } else { this.vor_counter = Math.max(-3, this.vor_counter - 1); } if (this.vor_counter < -2) { this.use_vor = false; } const shaders_to_update = [ this.shader, this.image_shader, this.masked_image_shader, ]; // If not playing, still reder with different viewbox, so people can still pan etc. if (!this.playing) { this.last_time = time; shaders_to_update.forEach((shader) => { shader.uniform( GL, "u_viewbox", new Uniform4f(this.resizer.get_viewbox()) ); }) this.vor_shader.uniform( GL, "u_viewbox", new Uniform4f(this.resizer.get_viewbox()) ); this.renderer.render(GL); return; } // Check if turn is still correct if (time > this.last_time + ms_per_frame) { this.last_time = time; this.updateTurn(this.frame + 1); if (this.frame == this.turn_count - 1) { this.playing = false; } } // Do GL things GL.bindFramebuffer(GL.FRAMEBUFFER, null); GL.viewport(0, 0, GL.canvas.width, GL.canvas.height); GL.clear(GL.COLOR_BUFFER_BIT | GL.DEPTH_BUFFER_BIT); this.vor_shader.uniform( GL, "u_time", new Uniform1f((time - this.last_time) / ms_per_frame) ); this.vor_shader.uniform( GL, "u_viewbox", new Uniform4f(this.resizer.get_viewbox()) ); this.vor_shader.uniform(GL, "u_resolution", new Uniform2f(RESOLUTION)); this.vor_shader.uniform(GL, "u_vor", new UniformBool(this.use_vor)); shaders_to_update.forEach((shader) => { shader.uniform( GL, "u_time", new Uniform1f((time - this.last_time) / ms_per_frame) ); shader.uniform( GL, "u_mouse", new Uniform2f(this.resizer.get_mouse_pos()) ); shader.uniform( GL, "u_viewbox", new Uniform4f(this.resizer.get_viewbox()) ); shader.uniform(GL, "u_resolution", new Uniform2f(RESOLUTION)); }); // Render this.renderer.render(GL); COUNTER.frame_end(); } updateTurn(turn: number) { this.frame = Math.max(0, turn); const new_frame = this.game.update_turn(this.frame); if (new_frame < this.frame) { this.frame = new_frame; this.playing = false; } else { this._update_state(); this.playing = true; } this.updateTurnCounters(); } updateTurnCounters() { ELEMENTS["turnCounter"].innerHTML = this.frame + " / " + (this.turn_count - 1); ELEMENTS["turnSlider"].value = this.frame + ""; ELEMENTS["turnSlider"].max = this.turn_count - 1 + ""; } handleKey(event: KeyboardEvent) { // Space if (event.keyCode == 32) { if (this.playing) { this.playing = false; } else { this.playing = true; } } // Arrow left if (event.keyCode == 37) { // This feels more natural than -1 what it should be, I think this.updateTurn(this.frame - 2); } // Arrow right if (event.keyCode == 39) { this.updateTurn(this.frame + 1); } // d key if (event.keyCode == 68) { ELEMENTS["speed"].value = ms_per_frame + 10 + ""; ELEMENTS["speed"].onchange(undefined); } // a key if (event.keyCode == 65) { ELEMENTS["speed"].value = Math.max(ms_per_frame - 10, 0) + ""; ELEMENTS["speed"].onchange(undefined); } } } var game_instance: GameInstance; var textures: Texture[]; var shaders: Dictionary; export async function set_instance(source: string): Promise { // TODO: embed shader programs if (!textures || !shaders) { const texture_promises = [ Texture.fromImage(GL, assets.fontPng, "font"), Texture.fromImage(GL, assets.shipPng, "ship"), Texture.fromImage(GL, assets.earthPng, "earth") ]; const shader_promies = [ (async () => <[string, ShaderFactory]>[ "normal", await ShaderFactory.create_factory( assets.simpleFragmentShader, assets.simpleVertexShader, ), ])(), (async () => <[string, ShaderFactory]>[ "vor", await ShaderFactory.create_factory( assets.vorFragmentShader, assets.vorVertexShader, ), ])(), (async () => <[string, ShaderFactory]>[ "image", await ShaderFactory.create_factory( assets.imageFragmentShader, assets.simpleVertexShader, ), ])(), (async () => <[string, ShaderFactory]>[ "masked_image", await ShaderFactory.create_factory( assets.maskedImageFragmentShader, assets.simpleVertexShader, ), ])(), ]; let shaders_array: [string, ShaderFactory][]; [textures, shaders_array] = await Promise.all([ Promise.all(texture_promises), Promise.all(shader_promies), ]); shaders = {}; shaders_array.forEach(([name, fac]) => (shaders[name] = fac)); } resizeCanvasToDisplaySize(CANVAS); game_instance = new GameInstance( Game.new(source), textures.slice(2), textures[1], textures[0], shaders ); set_loading(false); start(); return game_instance; } var _animating = false; export function start() { if (_animating) { // already running return; } _animating = true; requestAnimationFrame(step); } export function stop() { _animating = false; } function step(time: number) { if (game_instance) { game_instance.render(time); } if (_animating) { requestAnimationFrame(step); } }