This commit is contained in:
Kim Ravn Hansen
2025-09-30 10:10:03 +02:00
parent 8f458fbc34
commit 251e2e4fef
14 changed files with 292 additions and 848 deletions

View File

@@ -81,281 +81,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script> <script>
/**
* A class that creates an ASCII effect.
*
* The ASCII generation is based on [jsascii]{@link https://github.com/hassadee/jsascii/blob/master/jsascii.js}.
*
* @three_import import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
*/
class AsciiEffect {
/**
* Constructs a new ASCII effect.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {string} [charSet=' .:-=+*#%@'] - The char set.
* @param {AsciiEffect~Options} [options] - The configuration parameter.
*/
constructor(renderer, charSet = " .:-=+*#%@", options = {}) {
// ' .,:;=|iI+hHOE#`$';
// darker bolder character set from https://github.com/saw/Canvas-ASCII-Art/
// ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$'.split('');
// Some ASCII settings
const fResolution = options["resolution"] || 0.15;
const iScale = options["scale"] || 1;
const bColor = options["color"] || false;
const bAlpha = options["alpha"] || false;
const bBlock = options["block"] || false;
const bInvert = options["invert"] || false;
const strResolution = options["strResolution"] || "low";
let width, height;
const domElement = document.createElement("div");
domElement.style.cursor = "default";
const oAscii = document.createElement("table");
domElement.appendChild(oAscii);
let iWidth, iHeight;
let oImg;
/**
* Resizes the effect.
*
* @param {number} w - The width of the effect in logical pixels.
* @param {number} h - The height of the effect in logical pixels.
*/
this.setSize = function (w, h) {
width = w;
height = h;
renderer.setSize(w, h);
initAsciiSize();
};
/**
* When using this effect, this method should be called instead of the
* default {@link WebGLRenderer#render}.
*
* @param {Object3D} scene - The scene to render.
* @param {Camera} camera - The camera.
*/
this.render = function (scene, camera) {
renderer.render(scene, camera);
asciifyImage(oAscii);
};
/**
* The DOM element of the effect. This element must be used instead of the
* default {@link WebGLRenderer#domElement}.
*
* @type {HTMLDivElement}
*/
this.domElement = domElement;
// Throw in ascii library from https://github.com/hassadee/jsascii/blob/master/jsascii.js (MIT License)
function initAsciiSize() {
iWidth = Math.floor(width * fResolution);
iHeight = Math.floor(height * fResolution);
oCanvas.width = iWidth;
oCanvas.height = iHeight;
// oCanvas.style.display = "none";
// oCanvas.style.width = iWidth;
// oCanvas.style.height = iHeight;
oImg = renderer.domElement;
if (oImg.style.backgroundColor) {
oAscii.rows[0].cells[0].style.backgroundColor = oImg.style.backgroundColor;
oAscii.rows[0].cells[0].style.color = oImg.style.color;
}
oAscii.cellSpacing = "0";
oAscii.cellPadding = "0";
const oStyle = oAscii.style;
oStyle.whiteSpace = "pre";
oStyle.margin = "0px";
oStyle.padding = "0px";
oStyle.letterSpacing = fLetterSpacing + "px";
oStyle.fontFamily = strFont;
oStyle.fontSize = fFontSize + "px";
oStyle.lineHeight = fLineHeight + "px";
oStyle.textAlign = "left";
oStyle.textDecoration = "none";
}
const strFont = "courier new, monospace";
const oCanvasImg = renderer.domElement;
const oCanvas = document.createElement("canvas");
if (!oCanvas.getContext) {
return;
}
const oCtx = oCanvas.getContext("2d");
if (!oCtx.getImageData) {
return;
}
let aCharList;
if (charSet) {
aCharList = charSet.split("");
} else {
const aDefaultCharList = " .,:;i1tfLCG08@".split("");
const aDefaultColorCharList = " CGO08@".split("");
aCharList = bColor ? aDefaultColorCharList : aDefaultCharList;
}
// Setup dom
const fFontSize = (2 / fResolution) * iScale;
const fLineHeight = (2 / fResolution) * iScale;
// adjust letter-spacing for all combinations of scale and resolution to get it to fit the image width.
let fLetterSpacing = 0;
if (strResolution == "low") {
switch (iScale) {
case 1:
fLetterSpacing = -1;
break;
case 2:
case 3:
fLetterSpacing = -2.1;
break;
case 4:
fLetterSpacing = -3.1;
break;
case 5:
fLetterSpacing = -4.15;
break;
}
}
if (strResolution == "medium") {
switch (iScale) {
case 1:
fLetterSpacing = 0;
break;
case 2:
fLetterSpacing = -1;
break;
case 3:
fLetterSpacing = -1.04;
break;
case 4:
case 5:
fLetterSpacing = -2.1;
break;
}
}
if (strResolution == "high") {
switch (iScale) {
case 1:
case 2:
fLetterSpacing = 0;
break;
case 3:
case 4:
case 5:
fLetterSpacing = -1;
break;
}
}
// can't get a span or div to flow like an img element, but a table works?
// convert img element to ascii
function asciifyImage(oAscii) {
oCtx.clearRect(0, 0, iWidth, iHeight);
oCtx.drawImage(oCanvasImg, 0, 0, iWidth, iHeight);
const oImgData = oCtx.getImageData(0, 0, iWidth, iHeight).data;
// Coloring loop starts now
let strChars = "";
// console.time('rendering');
for (let y = 0; y < iHeight; y += 2) {
for (let x = 0; x < iWidth; x++) {
const iOffset = (y * iWidth + x) * 4;
const iRed = oImgData[iOffset];
const iGreen = oImgData[iOffset + 1];
const iBlue = oImgData[iOffset + 2];
const iAlpha = oImgData[iOffset + 3];
let iCharIdx;
let fBrightness;
fBrightness = (0.3 * iRed + 0.59 * iGreen + 0.11 * iBlue) / 255;
// fBrightness = (0.3*iRed + 0.5*iGreen + 0.3*iBlue) / 255;
if (iAlpha == 0) {
// should calculate alpha instead, but quick hack :)
//fBrightness *= (iAlpha / 255);
fBrightness = 1;
}
iCharIdx = Math.floor((1 - fBrightness) * (aCharList.length - 1));
if (bInvert) {
iCharIdx = aCharList.length - iCharIdx - 1;
}
// good for debugging
//fBrightness = Math.floor(fBrightness * 10);
//strThisChar = fBrightness;
let strThisChar = aCharList[iCharIdx];
if (strThisChar === undefined || strThisChar == " ") strThisChar = "&nbsp;";
if (bColor) {
strChars +=
"<span style='" +
"color:rgb(" +
iRed +
"," +
iGreen +
"," +
iBlue +
");" +
(bBlock
? "background-color:rgb(" + iRed + "," + iGreen + "," + iBlue + ");"
: "") +
(bAlpha ? "opacity:" + iAlpha / 255 + ";" : "") +
"'>" +
strThisChar +
"</span>";
} else {
strChars += strThisChar;
}
}
strChars += "<br/>";
}
oAscii.innerHTML = `<tr><td style="display:block;width:${width}px;height:${height}px;overflow:hidden">${strChars}</td></tr>`;
// console.timeEnd('rendering');
// return oAscii;
}
}
}
/** /**
* This type represents configuration settings of `AsciiEffect`. * This type represents configuration settings of `AsciiEffect`.
* *

View File

@@ -72,12 +72,12 @@
</head> </head>
<body> <body>
<div id="gameContainer"> <div id="gameContainer">
<div id="viewport"></div> <div id="threejs" style="border: 2px solid green; background-color: #222"></div>
<div id="minimap"></div> <div id="minimap"></div>
<div id="compass">orientation</div> <div id="compass">orientation</div>
<div id="viewport"></div>
<div id="mapInput"> <div id="mapInput">
←→↑↓ ←→↑↓
<br /> <br />
@@ -86,13 +86,13 @@
############################################################ ############################################################
############################################################ ############################################################
## ################# ######################## ## ################# ########################
## # # ################# # ## ######################## ## # ### ################# # ## ########################
## #S ################# # ## ################ ## #S# ################# # ## ################
## # # ################# # ## #### #### ## # # # ################# # ## #### ####
## M # # ## # #### # # #### ## M # # ## # #### # # ####
###### #################### ## #### # #### ###### #################### ## #### # ####
###### #################### # ## # # #### #### ###### #################### # ## # # #### ####
###### #################### # #### ######M#################### # ####
###### #################### ########## #### #### ###### #################### ########## #### ####
###### #################### ########## # # #### # # #### ###### #################### ########## # # #### # # ####
###### #################### ########## #### # # #### ###### #################### ########## #### # # ####
@@ -100,7 +100,7 @@
###### #################### ############################ ###### #################### ############################
###### #################### # ############################ ###### #################### # ############################
###### #################### # ############################ ###### #################### # ############################
###### #################### # ############################ ######M#################### # ############################
###### ## ########### ############################ ###### ## ########### ############################
###### ## ########### # # ############################ ###### ## ########### # # ############################
###### ## ########### ######## ############ ###### ## ########### ######## ############

View File

@@ -1,7 +1,6 @@
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js"; import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
import { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_person_renderer.js"; import { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_person_renderer.js";
import { MiniMapRenderer } from "../ascii_minimap_renderer.js"; import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
import { Texture } from "./ascii_textureloader.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
import { TileMap } from "./ascii_tile_map.js"; import { TileMap } from "./ascii_tile_map.js";
import eobWallUrl1 from "./eob1.png"; import eobWallUrl1 from "./eob1.png";
@@ -141,8 +140,8 @@ class DungeonCrawler {
return; return;
} }
this.rendering.firstPersonRenderer.renderFrame( this.rendering.firstPersonRenderer.renderFrame(
camX + 0.5, // add .5 to get camera into center of cell camX, // add .5 to get camera into center of cell
camY + 0.5, // add .5 to get camera into center of cell camY, // add .5 to get camera into center of cell
angle, angle,
); );
} }
@@ -156,7 +155,7 @@ class DungeonCrawler {
this.map = TileMap.fromText(mapString); this.map = TileMap.fromText(mapString);
this.player._posV = this.map.findFirst({ startLocation: true }); this.player._posV = this.map.findFirst({ isStartLocation: true });
if (!this.player._posV) { if (!this.player._posV) {
throw new Error("Could not find a start location for the player"); throw new Error("Could not find a start location for the player");
@@ -164,31 +163,18 @@ class DungeonCrawler {
this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map); this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
const textureUrls = [eobWallUrl1, gnollSpriteUrl]; const textureFilenames = [eobWallUrl1, gnollSpriteUrl];
const textures = new Array(textureUrls.length).fill();
let textureLoadCount = 0;
textureUrls.forEach((url, textureId) => {
Texture.fromSource(url).then((texture) => {
textures[textureId] = texture;
textureLoadCount++;
if (textureLoadCount < textureUrls.length) {
return;
}
this.rendering.firstPersonRenderer = new FirstPersonRenderer( this.rendering.firstPersonRenderer = new FirstPersonRenderer(
this.rendering.firstPersonWindow, this.rendering.firstPersonWindow,
this.map, this.map,
textures, textureFilenames,
this.rendering.options, this.rendering.options,
); );
this.rendering.firstPersonRenderer.onReady = () => {
this.render(); this.render();
this.renderMinimap(); this.renderMinimap();
this.renderCompass(); this.renderCompass();
};
console.debug("renderer ready", textures);
});
});
} }
startTurnAnimation(quarterTurns = 1) { startTurnAnimation(quarterTurns = 1) {
@@ -237,8 +223,8 @@ class DungeonCrawler {
this.player._posV, this.player._posV,
this.player.angle, this.player.angle,
); );
this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall // this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
return false; // return false;
} }
this.animation = { this.animation = {
@@ -267,10 +253,10 @@ class DungeonCrawler {
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD), KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD), ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD), ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
ArrowLeft: () => this.startTurnAnimation(-1), ArrowLeft: () => this.startTurnAnimation(1),
ArrowRight: () => this.startTurnAnimation(1), ArrowRight: () => this.startTurnAnimation(-1),
KeyQ: () => this.startTurnAnimation(-1), KeyQ: () => this.startTurnAnimation(1),
KeyE: () => this.startTurnAnimation(1), KeyE: () => this.startTurnAnimation(-1),
}; };
this.keys.names = Object.keys(this.keys.handlers); this.keys.names = Object.keys(this.keys.handlers);

View File

@@ -1,429 +1,214 @@
import { NRGBA } from "./ascii_textureloader.js";
import { TileMap, Tile } from "./ascii_tile_map.js"; import { TileMap, Tile } from "./ascii_tile_map.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
import * as THREE from "three";
import eobWallUrl1 from "./eob1.png";
import gnollSpriteUrl from "./gnoll.png";
/**
* Which side of a tile did the ray strike
*/
export const Side = {
X_AXIS: 0,
Y_AXIS: 1,
};
class RayCollision {
mapX = 0;
mapY = 0;
rayLength = 0;
side = Side.X_AXIS;
/** @type {Tile} */
tile;
}
class RayCastResult {
hitWall = false;
hitSprite = false;
wallCollision = new RayCollision();
/** @type {RayCollision[]} */
collisions = [];
}
/**
* @typedef {object} FirstPersonRendererOptions
* @property {string} wallChar
* @property {NRGBA} floorColor
* @property {string} floorChar
* @property {NRGBA} ceilingColor
* @property {string} ceilingChar
* @property {number} viewDistance
* @property {number} fov
*/
/**
* @type {FirstPersonRendererOptions}
*/
export const DefaultRendererOptions = { export const DefaultRendererOptions = {
wallChar: "W",
floorColor: new NRGBA(0.365, 0.165, 0.065),
floorChar: "f",
ceilingColor: new NRGBA(0.3, 0.3, 0.3),
ceilingChar: "c",
fadeOutColor: new NRGBA(0.3, 0.3, 0.3),
viewDistance: 5, viewDistance: 5,
fov: Math.PI / 3, // 60 degrees - good for spooky fov: Math.PI / 3, // 60 degrees - good for spooky
wallChar: "#",
floorColor: 0x654321,
floorChar: "f",
ceilingColor: 0x555555,
ceilingChar: "c",
fadeOutColor: 0x555555,
}; };
export class FirstPersonRenderer { export class FirstPersonRenderer {
/** /**
* @param {AsciiWindow} aWindow the window we render onto. * @param {AsciiWindow} aWindow the window we render onto.
* @param {TileMap} map * @param {TileMap} map
* @param {Texture[]} textures * @param {string[]} textureFilenames
* @param {FirstPersonRendererOptions} options
*/ */
constructor(aWindow, map, textures, options) { constructor(aWindow, map, textureFilenames, options) {
/** @constant @readonly @type {TileMap} */ const w = 600;
this.map = map; const h = 400;
/** @constant @readonly @type {AsciiWindow} */
this.window = aWindow;
/** @constant @readonly @type {number} */
this.fov = options.fov ?? DefaultRendererOptions.fov; this.fov = options.fov ?? DefaultRendererOptions.fov;
/** @constant @readonly @type {number} */
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance; this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
/** @constant @readonly @type {Texture[]} */ this.window = aWindow;
this.textures = textures; this.map = map;
/** @constant @readonly @type {string} */ this.scene = new THREE.Scene();
this.wallChar = options.wallChar ?? DefaultRendererOptions.wallChar; this.camera = new THREE.PerspectiveCamera((this.fov * 180) / Math.PI, w / h);
/** @constant @readonly @type {NRGBA} */ this.renderer = new THREE.WebGLRenderer({ antialias: false }); // Do not anti-alias, it could interfere with the conversion to ascii
this.floorColor = options.floorColor ?? DefaultRendererOptions.floorColor;
/** @constant @readonly @type {string} */
this.floorChar = options.floorChar ?? DefaultRendererOptions.floorChar;
/** @constant @readonly @type {NRGBA} */
this.ceilingColor = options.ceilingColor ?? DefaultRendererOptions.ceilingColor;
/** @constant @readonly @type {string} */
this.ceilingChar = options.ceilingChar ?? DefaultRendererOptions.ceilingChar;
/** //
* Pre-computed colors to use when drawing floors, ceilings and "fadeout" // Fog, Fadeout & Background
* //
* There is one entry for every screen row. this.scene.background = new THREE.Color(0);
* Each entry contains a color to use when drawing floors, ceilings, and "fadeout". this.scene.fog = new THREE.Fog(0, 0, this.viewDistance - 1);
*
* @constant @readonly @type {Array<Array<string>>}
*/
this.shades = [];
/** //
* Pre-compute the shades variable // Camera
*/ //
this.computeShades(); this.camera.up.set(0, 0, 1); // Z-up instead of Y-up
//
// Torch
//
this.torch = new THREE.PointLight(0xffffff, 0.9, this.viewDistance, 2); // https://threejs.org/docs/#api/en/lights/PointLight
this.torch.position.copy(this.camera.position);
this.scene.add(this.torch);
//
// Sprites
//
/** @type {THREE.Sprite[]} */
this.sprites = [];
//
this.initMap();
//
this.renderer.setSize(w, h);
document.getElementById("threejs").appendChild(this.renderer.domElement);
this.renderFrame();
} }
computeShades() { initMap() {
const screenHeight = this.window.height; const wallPlanes = [];
const halfScreenHeight = screenHeight / 2; const sprites = [];
const lineHeight = Math.floor(screenHeight / this.viewDistance);
const minY = Math.floor(-lineHeight / 2 + halfScreenHeight); // if y lower than minY, then we're painting ceiling
const maxY = Math.floor(lineHeight / 2 + halfScreenHeight); // if y higher than maxY then we're painting floor
for (let y = 0; y < screenHeight; y++) { /** @type {Map<number,Array} */
if (y < minY) { this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => {
// //
// y is smaller than minY. This means we're painting above if (tile.isStartLocation) {
// the walls, i.e. painting the ceiling. this.camera.position.set(x, y, 0);
// The closer y is to minY, the farther away this part of the this.camera.lookAt(x, y - 1, 0);
// ceiling is. this.torch.position.copy(this.camera.position);
//
// High diff => near console.log("Initial Camera Position:", this.camera.position);
// Low diff => far return;
//
const diff = minY - y;
this.shades.push([this.ceilingChar, this.ceilingColor.mulledRGB(diff / minY).toCSS()]);
} else if (y >= maxY) {
//
// Floor
//
const diff = y - maxY;
this.shades.push([this.floorChar, this.floorColor.mulledRGB(diff / minY).toCSS()]);
} else {
//
// The darkness at the end of the tunnel
//
this.shades.push([" ", "#000"]);
} }
if (tile.isWall) {
if (!this.map.isWall(x, y + 1)) {
wallPlanes.push([x, y + 0.5, Math.PI * 0.0]);
}
if (!this.map.isWall(x + 1, y)) {
wallPlanes.push([x + 0.5, y, Math.PI * 0.5]);
}
if (!this.map.isWall(x, y - 1)) {
wallPlanes.push([x, y - 0.5, Math.PI * 1.0]);
}
if (!this.map.isWall(x - 1, y)) {
wallPlanes.push([x - 0.5, y, Math.PI * 1.5]);
}
return;
}
if (tile.isSprite) {
console.log("Sprite", tile);
sprites.push([x, y, tile.textureId]);
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: 0x964b00 /* 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: 0x333333, 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 wallTex = new THREE.TextureLoader().load(eobWallUrl1, (texture) => {
texture.magFilter = THREE.NearestFilter; // no smoothing when scaling up
texture.minFilter = THREE.NearestFilter; // no mipmaps / no smoothing when scaling down
texture.generateMipmaps = false; // dont build mipmaps
});
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 instancedMesh = new THREE.InstancedMesh(
wallGeo,
new THREE.MeshStandardMaterial({ map: wallTex }),
wallPlanes.length,
);
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;
//
// Sprites
//
// Load a sprite texture
const tex = new THREE.TextureLoader().load(gnollSpriteUrl, (t) => {
t.magFilter = THREE.NearestFilter; // pixel-art crisp
t.minFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.wrapS = THREE.RepeatWrapping;
t.wrapT = THREE.RepeatWrapping;
t.repeat.set(1, 1);
});
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
for (const [x, y, textureId] of sprites) {
const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(
x,
y,
0, // z (stand on floor)
);
sprite.position.set(x, y, 0);
this.sprites.push(sprite);
this.scene.add(sprite);
console.log({ x, y, textureId });
} }
} }
renderFrame(posX, posY, dirAngle, commit = true) { renderFrame(posX, posY, dirAngle, commit = true) {
const benchmarkStart = performance.now(); this.renderer.render(this.scene, this.camera);
const screenWidth = this.window.width; const lookAtV = new THREE.Vector3(1, 0, 0);
lookAtV
.applyAxisAngle(new THREE.Vector3(0, 0, 1), dirAngle)
.normalize()
.add(this.camera.position);
/** @type {Map<number,Tile} The coordinates of all the tiles checked while rendering this frame*/ this.camera.position.x = posX;
const coordsChecked = new Map(); this.camera.position.y = posY;
for (let x = 0; x < screenWidth; x++) { this.torch.position.copy(this.camera.position);
const angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians this.torch.position.z += 0.25;
const rayAngle = dirAngle + angleOffset; this.camera.lookAt(lookAtV);
const rayDirX = Math.cos(rayAngle);
const rayDirY = Math.sin(rayAngle);
// Cast ray using our DDA function
const ray = this.castRay(posX, posY, rayDirX, rayDirY, coordsChecked);
// //
// Render a single screen column
this.renderColumn(x, ray, rayDirX, rayDirY, angleOffset);
}
const renderTime = performance.now() - benchmarkStart;
// Did it take more than 5ms to render the scene?
if (renderTime > 5) {
console.log("Rendering took a long time", { renderTime });
}
if (commit) { if (commit) {
requestAnimationFrame(() => {
const benchmarkStart = performance.now();
this.window.commitToDOM(); this.window.commitToDOM();
const commitTime = performance.now() - benchmarkStart;
if (commitTime > 5) {
console.log("Updating DOM took a long time:", { commitTime });
}
});
}
}
/**
* Render a column on the screen where the ray hit a wall.
* @param {number} x
* @param {RayCastResult} ray
* @param {number} rayDirX
* @param {number} rayDirY
* @param {number} angleOffset for far (in radians) is this column from the middle of the screen
*
* @protected
*/
renderColumn(x, ray, rayDirX, rayDirY, angleOffset) {
// //
// // Check if we hit anything at all
// if (ray.collisions.length === 0) {
// //
// // We didn't hit anything. Just paint floor, wall, and darkness
// for (let y = 0; y < this.window.height; y++) {
// const [char, color] = this.shades[y];
// this.window.put(x, y, char, color);
// }
// return;
// }
//
// // ALTERNATIVE always paint floor and ceiling
for (let y = 0; y < this.window.height; y++) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
}
for (const { rayLength, side, sampleU, tile } of ray.collisions) {
let distance = Math.max(rayLength * Math.cos(angleOffset), 1e-12); // Avoid divide by zero
//
// Calculate perspective.
//
const screenHeight = this.window.height;
const lineHeight = Math.round(screenHeight / distance); // using round() because floor() gives aberrations when distance == (n + 0.500)
const halfScreenHeight = screenHeight / 2;
const halfLineHeight = lineHeight / 2;
let minY = Math.floor(halfScreenHeight - halfLineHeight);
let maxY = Math.floor(halfScreenHeight + halfLineHeight);
let unsafeMinY = minY; // can be lower than zero - it happens when we get so close to a wall we cannot see top or bottom
if (minY < 0) {
minY = 0;
}
if (maxY >= screenHeight) {
maxY = screenHeight - 1;
}
//
// Pick texture (here grid value decides which texture)
//
const texture = this.textures[tile.textureId];
for (let y = 0; y < screenHeight; y++) {
//
// Are we hitting the ceiling?
//
if (y < minY || y > maxY) {
const [char, color] = this.shades[y];
this.window.put(x, y, char, color);
continue;
}
// // DEBUG LINES
// if (y === minY) {
// this.window.put(x, y, "m", "#0F0");
// continue;
// }
// if (y === maxY) {
// this.window.put(x, y, "M", "#F00");
// continue;
// }
//
// Map screen y to texture y
let sampleV = (y - unsafeMinY) / lineHeight; // y- coordinate of the texture point to sample
const color = texture.sample(sampleU, sampleV);
if (!Number.isFinite(color.a)) {
throw new Error("Waaat");
}
if (color.a === 0) {
continue;
}
//
// North-south walls are shaded differently from east-west walls
let shade = side === Side.X_AXIS ? 0.8 : 1.0; // MAGIC NUMBERS
//
// Dim walls that are far away
const lightLevel = 1 - rayLength / this.viewDistance;
//
// Darken the image
color.mulRGB(shade * lightLevel);
this.window.put(x, y, tile.sprite ? "#" : this.wallChar, color.toCSS()); // MAGIC CONSTANT "S"
}
}
}
/**
* @param {number} camX x-coordinate of the camera (is the same
* @param {number} camY y-coordinate of the camera
* @param {number} dirX x-coordinate of the normalized vector of the viewing direction of the camera.
* @param {number} dirX y-coordinate of the normalized vector of the viewing direction of the camera.
* @param {Set<number>} coodsChecked
*
* @returns {RayCastResult}
*
*/
castRay(camX, camY, dirX, dirY, coordsChecked) {
// Current map square
let mapX = Math.floor(camX);
let mapY = Math.floor(camY);
// Length of ray from one x or y-side to next x or y-side
const deltaDistX = dirX === 0 ? 1e15 : Math.abs(1 / dirX);
const deltaDistY = dirY === 0 ? 1e15 : Math.abs(1 / dirY);
// Step direction (+1 or -1) and initial sideDist[XY]
let stepX; // When DDA takes a horizontal step (on the map), how far should it move?
let stepY; // When DDA takes a vertical step (on the map), how far should it move?
let sideDistX; // How far has the ray moved horizontally (on the map) ?
let sideDistY; // How far has the ray moved vertically (on the map) ?
let side = Side.X_AXIS;
//
// Calculate how to move along the x-axis
if (dirX < 0) {
stepX = -1; // step left along the x-axis
sideDistX = (camX - mapX) * deltaDistX; // we've moved from the camera to the left edge of the tile
} else {
stepX = 1; // step right along the x-axis
sideDistX = (mapX + 1.0 - camX) * deltaDistX; // we've moved from the camera to the right edge of the tile
}
//
// Calculate how to move along the y-axis
if (dirY < 0) {
stepY = -1; // // step down along the y-axis
sideDistY = (camY - mapY) * deltaDistY; // we've move from the camera to the bottom edge of the tile
} else {
stepY = 1; // // step up along the y-axis
sideDistY = (mapY + 1.0 - camY) * deltaDistY; // we've moved from the camera to the top edge of the tile
}
/**
* Any sprites the ray has hit on its way.
* They are ordered in reverse order of closeness to the camera,
* so that if they are drawn in their array ordering, they will
* appear in the correct order on the screen.
*
* @type {RayCastResult}
*/
const result = new RayCastResult();
// DDA loop
while (!result.hitWall) {
//
// Check if ray is longer than viewDistance
if (Math.min(sideDistX, sideDistY) > this.viewDistance) {
return result;
}
//
// Check for out of bounds
if (mapX < 0 || mapX >= this.map.width || mapY < 0 || mapY >= this.map.height) {
return result;
}
let wallDist, sampleU;
//
// Should we step in the x- or y-direction
// DDA dictates we always move along the shortest vector
if (sideDistX < sideDistY) {
//
// Move horizontally
//
sideDistX += deltaDistX;
mapX += stepX;
side = Side.X_AXIS;
// Ray hit the east or west edge of the wall-tile
wallDist = (mapX - camX + (1 - stepX) / 2) / dirX;
sampleU = (camY + wallDist * dirY) % 1;
if (dirX > 0) {
sampleU = 1 - sampleU;
}
} else {
//
// Move vertically
//
sideDistY += deltaDistY;
mapY += stepY;
side = Side.Y_AXIS;
// Ray hit the north or south edge of the wall-tile
wallDist = (mapY - camY + (1 - stepY) / 2) / dirY;
sampleU = (camX + wallDist * dirX) % 1;
if (dirY < 0) {
sampleU = 1 - sampleU;
}
}
const tile = this.map.get(mapX, mapY);
coordsChecked.set(this.map.tileIdx(mapX, mapY), tile);
//
// --------------------------
// No collision? Move on
// --------------------------
if (!tile.collision) {
continue;
}
const rayLength = Math.hypot(
wallDist * dirX, //
wallDist * dirY, //
);
//
// Prepend the element to the array so rear-most sprites
// appear first in the array,
// enabling us to simply draw from back to front
const collision = new RayCollision();
collision.mapX = mapX;
collision.mapY = mapY;
collision.rayLength = rayLength;
collision.tile = tile;
collision.sampleU = sampleU;
collision.side = side;
result.collisions.unshift(collision);
//
// --------------------------------
// Algorithm stops if the ray hits
// a wall.
// -------------------------------
if (tile.wall) {
result.hitWall = true;
return result;
}
} }
} }
} }

View File

@@ -1,105 +0,0 @@
export class NRGBA {
//
constructor(r = 0, g = 0, b = 0, a = 1) {
this.r = r;
this.g = g;
this.b = b;
this.a = a;
}
mulRGB(factor) {
this.r *= factor;
this.g *= factor;
this.b *= factor;
}
mulledRGB(factor) {
return new NRGBA(this.r * factor, this.g * factor, this.b * factor, this.a);
}
get dR() {
return ((this.r * 255) | 0) % 256;
}
get dG() {
return ((this.g * 255) | 0) % 256;
}
get dB() {
return ((this.b * 255) | 0) % 256;
}
get dA() {
return ((this.a * 255) | 0) % 256;
}
toCSS() {
return (
"#" + // prefix
this.dR.toString(16).padStart(2, "0") +
this.dG.toString(16).padStart(2, "0") +
this.dB.toString(16).padStart(2, "0") +
this.dA.toString(16).padStart(2, "0")
);
}
}
/**
* Texture class,
* represents a single texture
*/
export class Texture {
static async fromSource(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const result = new Texture(img.width, img.height, imageData.data);
resolve(result);
} catch (error) {
reject(error);
}
};
img.onerror = () => reject(new Error(`Failed to load texture: ${src}`));
img.src = src;
});
}
constructor(width, height, data) {
/** @type {number} */
this.width = width;
/** @type {number} */
this.height = height;
/** @type {Uint8ClampedArray} */
this.data = data;
}
/**
* Bilinear sampling for smooth texture filtering
*
* @param {number} u the "x" coordinate of the texture sample pixel. Normalized to [0...1]
* @param {number} w the "y" coordinate of the texture sample pixel. Normalized to [0...1]
*
* @returns {NRGBA}
*/
sample(u, v) {
const x = Math.min(this.width - 1, Math.round(u * this.width));
const y = Math.min(this.height - 1, Math.round(v * this.height));
const index = (y * this.width + x) * 4;
return new NRGBA(
this.data[index + 0] / 255,
this.data[index + 1] / 255,
this.data[index + 2] / 255,
this.data[index + 3] / 255,
);
}
}

View File

@@ -1,6 +1,5 @@
import { Vector2i, Orientation } from "./ascii_types.js"; import { Vector2i, Orientation } from "./ascii_types.js";
import { AsciiWindow } from "./ascii_window.js"; import { AsciiWindow } from "./ascii_window.js";
import { Texture } from "./ascii_textureloader.js";
export class Tile { export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/ /** @type {string} How should this tile be rendered on the minimap.*/
@@ -10,16 +9,16 @@ export class Tile {
minimapColor = "#fff"; minimapColor = "#fff";
/** @type {boolean} Should this be rendered as a wall? */ /** @type {boolean} Should this be rendered as a wall? */
wall = false; isWall = false;
/** @type {boolean} is this tile occupied by a sprite? */ /** @type {boolean} is this tile occupied by a sprite? */
sprite = false; isSprite = false;
/** @type {boolean} Can the player walk here? */ /** @type {boolean} Can the player walk here? */
traversable = true; traversable = true;
/** @type {boolean} Is this where they player starts? */ /** @type {boolean} Is this where they player starts? */
startLocation = false; isStartLocation = false;
/** @type {boolean} Is this where they player starts? */ /** @type {boolean} Is this where they player starts? */
textureId = 0; textureId = 0;
@@ -34,7 +33,7 @@ export class Tile {
} }
get collision() { get collision() {
return this.wall || this.sprite; return this.isWall || this.isSprite;
} }
} }
@@ -45,7 +44,7 @@ export const defaultLegend = Object.freeze({
"": new Tile({ "": new Tile({
minimapChar: " ", minimapChar: " ",
traversable: true, traversable: true,
wall: false, isWall: false,
}), }),
// //
@@ -53,14 +52,14 @@ export const defaultLegend = Object.freeze({
" ": new Tile({ " ": new Tile({
minimapChar: " ", minimapChar: " ",
traversable: true, traversable: true,
wall: false, isWall: false,
}), }),
// //
// Default wall // Default wall
"#": new Tile({ "#": new Tile({
minimapChar: "#", minimapChar: "#",
traversable: false, traversable: false,
wall: true, isWall: true,
textureId: 0, textureId: 0,
}), }),
@@ -69,8 +68,8 @@ export const defaultLegend = Object.freeze({
minimapChar: "M", minimapChar: "M",
minimapColor: "#f00", minimapColor: "#f00",
traversable: false, traversable: false,
wall: false, isWall: false,
sprite: true, isSprite: true,
}), }),
// //
@@ -78,15 +77,15 @@ export const defaultLegend = Object.freeze({
"Ω": new Tile({ "Ω": new Tile({
minimapChar: "#", minimapChar: "#",
traversable: true, traversable: true,
wall: true, isWall: true,
}), }),
// //
// where the player starts // where the player starts
"S": new Tile({ "S": new Tile({
minimapChar: "S", // "Š", minimapChar: "S", // "Š",
traversable: true, traversable: true,
wall: false, isWall: false,
startLocation: true, isStartLocation: true,
}), }),
}); });
@@ -150,7 +149,7 @@ export class TileMap {
/** @constant @readonly @type {Tile[][]} */ /** @constant @readonly @type {Tile[][]} */
this.tiles = tiles; this.tiles = tiles;
/** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */ /** @type {Tile} when probing a coordinate outside the map, this is the tile that is returned */
this.outOfBoundsWall = this.findFirst({ wall: true }); this.outOfBoundsWall = this.findFirst({ isWall: true });
} }
toString() { toString() {
@@ -185,7 +184,7 @@ export class TileMap {
return true; return true;
} }
return this.tiles[y][x].wall; return this.tiles[y][x].isWall;
} }
findFirst(criteria) { findFirst(criteria) {
@@ -242,8 +241,29 @@ export class TileMap {
getAreaAround(x, y, radius) { getAreaAround(x, y, radius) {
return this.getArea(x - radius, y - radius, x + radius, y + radius); return this.getArea(x - radius, y - radius, x + radius, y + radius);
} }
isVisible(x, y) {
//
// At least one of the four cardinal neighbours
// must be non-wall in order for a tile to be
// visible
if (!this.isWall(x - 1, y)) {
return true;
}
if (!this.isWall(x + 1, y)) {
return true;
}
if (!this.isWall(x, y - 1)) {
return true;
}
if (!this.isWall(x, y + 1)) {
return true;
}
return false;
}
} }
if (Math.PI < 0 && AsciiWindow && Texture && Orientation) { if (Math.PI < 0 && AsciiWindow && Orientation) {
("STFU Linda"); ("STFU Linda");
} }

View File

@@ -8,11 +8,11 @@ export const PI_OVER_TWO = Math.PI / 2;
export const Orientation = { export const Orientation = {
/** @constant @readonly @type {number} Going east increases X */ /** @constant @readonly @type {number} Going east increases X */
EAST: 0, EAST: 0,
/** @constant @readonly @type {number} Going south increases Y */ /** @constant @readonly @type {number} Going south decreases Y */
SOUTH: 1, SOUTH: 1,
/** @constant @readonly @type {number} Going west decreases X */ /** @constant @readonly @type {number} Going west decreases X */
WEST: 2, WEST: 2,
/** @constant @readonly @type {number} Going south decreases Y */ /** @constant @readonly @type {number} Going south increases Y */
NORTH: 3, NORTH: 3,
}; };
@@ -22,9 +22,9 @@ export const Orientation = {
*/ */
export const RelativeMovement = { export const RelativeMovement = {
FORWARD: 0, FORWARD: 0,
LEFT: 3, LEFT: 1,
BACKWARD: 2, BACKWARD: 2,
RIGHT: 1, RIGHT: 3,
}; };
export class Vector2i { export class Vector2i {

6
node_modules/.package-lock.json generated vendored
View File

@@ -3262,6 +3262,12 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -1,15 +1,31 @@
{ {
"hash": "a9cc42de", "hash": "5eac6a41",
"configHash": "86a557ed", "configHash": "86a557ed",
"lockfileHash": "56518f4e", "lockfileHash": "3ceab950",
"browserHash": "f6412460", "browserHash": "20105502",
"optimized": { "optimized": {
"sprintf-js": { "sprintf-js": {
"src": "../../sprintf-js/src/sprintf.js", "src": "../../sprintf-js/src/sprintf.js",
"file": "sprintf-js.js", "file": "sprintf-js.js",
"fileHash": "41b15421", "fileHash": "089e5f0c",
"needsInterop": true "needsInterop": true
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "22510eaa",
"needsInterop": false
},
"three/src/math/MathUtils.js": {
"src": "../../three/src/math/MathUtils.js",
"file": "three_src_math_MathUtils__js.js",
"fileHash": "f611651c",
"needsInterop": false
} }
}, },
"chunks": {} "chunks": {
"chunk-BUSYA2B4": {
"file": "chunk-BUSYA2B4.js"
}
}
} }

View File

@@ -1,7 +1,6 @@
var __getOwnPropNames = Object.getOwnPropertyNames; import {
var __commonJS = (cb, mod) => function __require() { __commonJS
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; } from "./chunk-BUSYA2B4.js";
};
// node_modules/sprintf-js/src/sprintf.js // node_modules/sprintf-js/src/sprintf.js
var require_sprintf = __commonJS({ var require_sprintf = __commonJS({

File diff suppressed because one or more lines are too long

7
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"figlet": "^1.8.2", "figlet": "^1.8.2",
"sprintf-js": "^1.1.3", "sprintf-js": "^1.1.3",
"three": "^0.180.0",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {
@@ -3212,6 +3213,12 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/three": {
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"figlet": "^1.8.2", "figlet": "^1.8.2",
"sprintf-js": "^1.1.3", "sprintf-js": "^1.1.3",
"three": "^0.180.0",
"ws": "^8.14.2" "ws": "^8.14.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -126,3 +126,7 @@ export class Scene {
onColon__hi = "Hoe"; onColon__hi = "Hoe";
} }
if (Math.PI < 0 && Session && Prompt) {
("STFU Linda");
}