394 lines
13 KiB
JavaScript
Executable File
394 lines
13 KiB
JavaScript
Executable File
import { TileMap } from "./ascii_tile_map.js";
|
||
import { PlayerStartTile, Tile } from "./ascii_tile_types.js";
|
||
import { AsciiWindow } from "./ascii_window.js";
|
||
import * as THREE from "three";
|
||
import { Vector3 } from "three";
|
||
|
||
export const DefaultRendererOptions = {
|
||
viewDistance: 5, // number of tiles we can see ahead
|
||
fov: 70, // degrees
|
||
|
||
floorColor: 0x654321,
|
||
ceilingColor: 0x555555,
|
||
|
||
commitToDOM: true,
|
||
|
||
fillChar: "#",
|
||
};
|
||
|
||
export class FirstPersonRenderer {
|
||
/**
|
||
* @param {AsciiWindow} aWindow the window we render onto.
|
||
* @param {TileMap} map
|
||
*/
|
||
constructor(aWindow, map, options) {
|
||
this.map = map;
|
||
this.window = aWindow;
|
||
|
||
//
|
||
// Window geometry
|
||
//
|
||
this.widthPx = aWindow.htmlElement.clientWidth;
|
||
this.heightPx = aWindow.htmlElement.clientHeight;
|
||
this.asciiWidth = aWindow.width;
|
||
this.asciiHeight = aWindow.height;
|
||
this.aaspect = this.widthPx / this.heightPx;
|
||
|
||
//
|
||
// Rendering options
|
||
//
|
||
this.fov = options.fov ?? DefaultRendererOptions.fov;
|
||
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
|
||
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
|
||
this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
|
||
this.commitToDOM = options.commitToDOM ?? DefaultRendererOptions.commitToDOM;
|
||
this.fillChar = options.fillChar ?? DefaultRendererOptions.fillChar;
|
||
|
||
//
|
||
// THREE variables
|
||
//
|
||
this.scene = new THREE.Scene();
|
||
this.mainCamera = new THREE.PerspectiveCamera(this.fov, this.aspect, 0.1, this.viewDistance);
|
||
this.renderer = new THREE.WebGLRenderer({
|
||
antialias: false, // Do not AA - it ruins asciification
|
||
preserveDrawingBuffer: true, // Preserve the rendering buffer so we can access it during asciification
|
||
});
|
||
|
||
//
|
||
// Fog, Fadeout & Background
|
||
//
|
||
this.scene.background = new THREE.Color(0);
|
||
this.scene.fog = new THREE.Fog(0, 0, this.viewDistance);
|
||
|
||
//
|
||
// Camera
|
||
//
|
||
this.mainCamera.up.set(0, 0, 1); // Z-up instead of Y-up
|
||
|
||
//
|
||
// Torch
|
||
//
|
||
this.torch = new THREE.PointLight(0xffffff, 2, this.viewDistance * 2, 1); // https://threejs.org/docs/#api/en/lights/PointLight
|
||
this.torch.position.copy(this.mainCamera.position);
|
||
this.scene.add(this.torch);
|
||
|
||
//
|
||
// Caches
|
||
//
|
||
/** @type {Map<string|number,THREE.Texture} Textures - one per unique textureId (i.e. filename) */
|
||
this.textures = new Map();
|
||
/** @type {Map<string|number,THREE.Material} Sprite materials - one material per unique sprite texture */
|
||
this.spriteMaterials = new Map();
|
||
/** @type {THREE.Sprite[]} All roaming tiles that regularly needs their positions updated */
|
||
this.roamers = [];
|
||
|
||
//
|
||
this.initMap();
|
||
|
||
//
|
||
this.renderer.setSize(this.asciiWidth * 1, this.asciiHeight * 1);
|
||
this.renderFrame();
|
||
}
|
||
|
||
getTexture(textureId) {
|
||
console.debug("fetching texture", { textureId });
|
||
let texture = this.textures.get(textureId);
|
||
if (!texture) {
|
||
console.debug(" miss... loading texture", { textureId });
|
||
texture = new THREE.TextureLoader().load(`${textureId}.png`, (t) => {
|
||
t.magFilter = THREE.NearestFilter; // no smoothing when scaling up
|
||
t.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
|
||
t.generateMipmaps = false; // don’t build mipmaps
|
||
});
|
||
this.textures.set(textureId, texture);
|
||
}
|
||
|
||
if (!texture) {
|
||
console.warn(" texture could not be loaded", { textureId, texture });
|
||
}
|
||
|
||
return texture;
|
||
}
|
||
|
||
getSpriteMaterial(textureId) {
|
||
console.debug("fetching material", { textureId });
|
||
|
||
let material = this.spriteMaterials.get(textureId);
|
||
|
||
if (!material) {
|
||
console.log("Creating material", { textureId });
|
||
material = new THREE.SpriteMaterial({
|
||
map: this.getTexture(textureId),
|
||
transparent: true,
|
||
});
|
||
|
||
this.spriteMaterials.set(textureId, material);
|
||
}
|
||
|
||
return material;
|
||
}
|
||
|
||
initMap() {
|
||
const wallPlanes = [];
|
||
const roamers = [];
|
||
|
||
//
|
||
// -------------
|
||
// PARSE THE MAP
|
||
// -------------
|
||
/** @type {Map<number,Array} */
|
||
this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => {
|
||
tile.textureId !== null && tile.textureId !== undefined && this.getTexture(tile.textureId);
|
||
|
||
//
|
||
if (tile instanceof PlayerStartTile) {
|
||
//
|
||
// This is temporary - the one that calls render() will determine the camera's
|
||
// position and orientation
|
||
//
|
||
this.mainCamera.position.set(x, y, 0);
|
||
this.mainCamera.lookAt(x, y - 1, 0);
|
||
this.torch.position.copy(this.mainCamera.position);
|
||
|
||
console.log("Initial Camera Position:", this.mainCamera.position);
|
||
return;
|
||
}
|
||
|
||
if (tile.looksLikeWall) {
|
||
if (!this.map.looksLikeWall(x, y + 1)) {
|
||
wallPlanes.push([x, y + 0.5, Math.PI * 0.0]);
|
||
}
|
||
if (!this.map.looksLikeWall(x + 1, y)) {
|
||
wallPlanes.push([x + 0.5, y, Math.PI * 0.5]);
|
||
}
|
||
if (!this.map.looksLikeWall(x, y - 1)) {
|
||
wallPlanes.push([x, y - 0.5, Math.PI * 1.0]);
|
||
}
|
||
if (!this.map.looksLikeWall(x - 1, y)) {
|
||
wallPlanes.push([x - 0.5, y, Math.PI * 1.5]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (tile.isRoaming) {
|
||
roamers.push([x, y, tile]);
|
||
return;
|
||
}
|
||
|
||
// TODO: Sprites, doors, etc
|
||
});
|
||
|
||
//
|
||
// ---------------------------
|
||
// FLOOR (XY PLANE AT Z = -.5)
|
||
// ---------------------------
|
||
const floorGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
|
||
const floorMat = new THREE.MeshStandardMaterial({
|
||
color: this.floorColor /* side: THREE.DoubleSide */,
|
||
});
|
||
const floor = new THREE.Mesh(floorGeo, floorMat);
|
||
floor.position.set(this.map.width / 2, this.map.height / 2, -0.5);
|
||
this.scene.add(floor);
|
||
|
||
//
|
||
// -----------------------------
|
||
// CEILING (XY PLANE AT Z = .5)
|
||
// -----------------------------
|
||
const ceilingGeo = new THREE.PlaneGeometry(this.map.width, this.map.height);
|
||
const ceilingMat = new THREE.MeshStandardMaterial({
|
||
color: this.ceilingColor,
|
||
side: THREE.BackSide,
|
||
});
|
||
const ceiling = new THREE.Mesh(ceilingGeo, ceilingMat);
|
||
ceiling.position.set(this.map.width / 2, this.map.height / 2, 0.5);
|
||
this.scene.add(ceiling);
|
||
|
||
//
|
||
// ------
|
||
// WALLS
|
||
// ------
|
||
const wallGeo = new THREE.PlaneGeometry();
|
||
wallGeo.rotateX(Math.PI / 2); // Get the geometry-plane the right way up (z-up)
|
||
wallGeo.rotateY(Math.PI); // rotate textures to be the right way up
|
||
const wallTextureId = this.map.getReferenceWallTile().textureId;
|
||
|
||
const instancedMesh = new THREE.InstancedMesh(
|
||
wallGeo,
|
||
new THREE.MeshStandardMaterial({
|
||
map: this.getTexture(wallTextureId),
|
||
}),
|
||
wallPlanes.length,
|
||
);
|
||
|
||
instancedMesh.userData.parimaryMaterial = instancedMesh.material;
|
||
this.scene.add(instancedMesh);
|
||
|
||
// Temp objects for generating matrices
|
||
const dummy = new THREE.Object3D();
|
||
|
||
wallPlanes.forEach((coords, idx) => {
|
||
const [x, y, rot] = coords;
|
||
dummy.position.set(x, y, 0);
|
||
dummy.rotation.set(Math.PI, 0, rot);
|
||
dummy.updateMatrix();
|
||
instancedMesh.setMatrixAt(idx, dummy.matrix);
|
||
});
|
||
instancedMesh.instanceMatrix.needsUpdate = true;
|
||
|
||
//
|
||
// -------
|
||
// Roamers
|
||
// -------
|
||
//
|
||
// Roaming tiles (e.g. encounters)
|
||
//
|
||
for (const [x, y, tile] of roamers) {
|
||
const textureId = tile.textureId;
|
||
|
||
if (!textureId) {
|
||
console.warn("invalid textureId", { x, y, textureId });
|
||
}
|
||
|
||
const roamerSprite = new THREE.Sprite(this.getSpriteMaterial(textureId));
|
||
roamerSprite.position.set(x, y, 0);
|
||
roamerSprite.userData.tile = tile;
|
||
this.roamers.push(roamerSprite);
|
||
this.scene.add(roamerSprite);
|
||
}
|
||
}
|
||
|
||
renderFrame(camX, camY, camOrientation) {
|
||
//
|
||
// Camera and lighting
|
||
//
|
||
const camV = new Vector3(camX, camY, 0);
|
||
this.updateCameraPosition(camOrientation, camV);
|
||
this.torch.position.set(camV.x, camV.y, camV.z + 0.25);
|
||
|
||
//
|
||
// Update position of roaming entities
|
||
//
|
||
this.updateRoamsers(camV);
|
||
|
||
//
|
||
// Render the scene into an image
|
||
//
|
||
this.renderSceneImage();
|
||
|
||
//
|
||
// Convert the rendered image to ASCII
|
||
//
|
||
this.renderSceneASCII();
|
||
}
|
||
|
||
renderSceneImage() {
|
||
performance.mark("scene_render_start");
|
||
this.renderer.render(this.scene, this.mainCamera);
|
||
performance.mark("scene_render_end");
|
||
performance.measure("3D Scene Rendering", "scene_render_start", "scene_render_end");
|
||
}
|
||
|
||
updateCameraPosition(dirAngle, camV) {
|
||
const lookDirV = new Vector3(1, 0, 0)
|
||
.applyAxisAngle(new Vector3(0, 0, 1), dirAngle)
|
||
.setZ(0)
|
||
.normalize();
|
||
|
||
//
|
||
// The Point we're looking at.
|
||
//
|
||
const lookAtV = lookDirV.clone().add(camV);
|
||
lookAtV.z = 0;
|
||
|
||
this.mainCamera.position.copy(camV); // Move the camera
|
||
this.mainCamera.lookAt(lookAtV);
|
||
}
|
||
|
||
updateRoamsers(camV) {
|
||
this.roamers.forEach((roamerSprite) => {
|
||
/** @type {Tile} */
|
||
const tile = roamerSprite.userData.tile;
|
||
|
||
//
|
||
// The map position (vector) of the encounter
|
||
/** @type {Vector3} */
|
||
const roamerTilePosV = new THREE.Vector3(tile.currentPosX, tile.currentPosY, 0);
|
||
|
||
// -------------------------------------
|
||
// Move sprite visually closer to camera
|
||
// -------------------------------------
|
||
//
|
||
// Sprites look better if they are right on the
|
||
// edge of their tile, closest to the player.
|
||
//
|
||
//
|
||
// Direction from encounter to camera
|
||
const dirV = new Vector3().subVectors(roamerTilePosV, camV);
|
||
|
||
//
|
||
// Is the encounter too far away to see? (manhattan distance for
|
||
if (dirV.manhattanLength() > this.viewDistance) {
|
||
// Encounter is out of range is out of range, do nothing
|
||
return;
|
||
}
|
||
|
||
//
|
||
// Set sprite position to the edge of the tile that is closest to the camera
|
||
roamerSprite.position.copy(roamerTilePosV);
|
||
// Magic constant. 0.6 is visually appealing and makes the encounter/sprite
|
||
// look fairly close while still being able to see the entire sprite.
|
||
roamerSprite.position.addScaledVector(dirV.normalize(), -0.6);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Convert rendered image to ASCII (asciification)
|
||
*/
|
||
renderSceneASCII() {
|
||
performance.mark("asciification_start");
|
||
const gl = this.renderer.getContext();
|
||
const width = this.renderer.domElement.width;
|
||
const height = this.renderer.domElement.height;
|
||
|
||
const pixels = new Uint8Array(width * height * 4);
|
||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||
|
||
let idx = 0;
|
||
for (let y = height - 1; y >= 0; y--) {
|
||
for (let x = 0; x < width; x++) {
|
||
const r = pixels[idx];
|
||
const g = pixels[idx + 1];
|
||
const b = pixels[idx + 2];
|
||
|
||
const cssColor =
|
||
"#" + //
|
||
r.toString(16).padStart(2, "0") +
|
||
g.toString(16).padStart(2, "0") +
|
||
b.toString(16).padStart(2, "0");
|
||
|
||
this.window.put(x, y, this.fillChar, cssColor);
|
||
|
||
idx += 4;
|
||
}
|
||
}
|
||
performance.mark("asciification_end");
|
||
performance.measure(
|
||
"Asciification", // The name for our measurement
|
||
"asciification_start", // The starting mark
|
||
"asciification_end",
|
||
);
|
||
|
||
//
|
||
if (this.commitToDOM) {
|
||
performance.mark("dom_commit_start");
|
||
this.window.commitToDOM();
|
||
performance.mark("dom_commit_end");
|
||
performance.measure("DOM Commit", "dom_commit_start", "dom_commit_end");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Math.PI < 0 && AsciiWindow && TileMap && Tile) {
|
||
("STFU Linda");
|
||
}
|