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>
/**
* 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`.
*

View File

@@ -72,12 +72,12 @@
</head>
<body>
<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="compass">orientation</div>
<div id="viewport"></div>
<div id="mapInput">
←→↑↓
<br />
@@ -86,13 +86,13 @@
############################################################
############################################################
## ################# ########################
## # # ################# # ## ########################
## #S ################# # ## ################
## # # ################# # ## #### ####
## # ### ################# # ## ########################
## #S# ################# # ## ################
## # # # ################# # ## #### ####
## 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 { DefaultRendererOptions, FirstPersonRenderer } from "./ascii_first_person_renderer.js";
import { MiniMapRenderer } from "../ascii_minimap_renderer.js";
import { Texture } from "./ascii_textureloader.js";
import { AsciiWindow } from "./ascii_window.js";
import { TileMap } from "./ascii_tile_map.js";
import eobWallUrl1 from "./eob1.png";
@@ -141,8 +140,8 @@ class DungeonCrawler {
return;
}
this.rendering.firstPersonRenderer.renderFrame(
camX + 0.5, // add .5 to get camera into center of cell
camY + 0.5, // add .5 to get camera into center of cell
camX, // add .5 to get camera into center of cell
camY, // add .5 to get camera into center of cell
angle,
);
}
@@ -156,7 +155,7 @@ class DungeonCrawler {
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) {
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);
const textureUrls = [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;
}
const textureFilenames = [eobWallUrl1, gnollSpriteUrl];
this.rendering.firstPersonRenderer = new FirstPersonRenderer(
this.rendering.firstPersonWindow,
this.map,
textures,
textureFilenames,
this.rendering.options,
);
this.rendering.firstPersonRenderer.onReady = () => {
this.render();
this.renderMinimap();
this.renderCompass();
console.debug("renderer ready", textures);
});
});
};
}
startTurnAnimation(quarterTurns = 1) {
@@ -237,8 +223,8 @@ class DungeonCrawler {
this.player._posV,
this.player.angle,
);
this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
return false;
// this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
// return false;
}
this.animation = {
@@ -267,10 +253,10 @@ class DungeonCrawler {
KeyW: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowUp: () => this.startMoveAnimation(RelativeMovement.FORWARD),
ArrowDown: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
ArrowLeft: () => this.startTurnAnimation(-1),
ArrowRight: () => this.startTurnAnimation(1),
KeyQ: () => this.startTurnAnimation(-1),
KeyE: () => this.startTurnAnimation(1),
ArrowLeft: () => this.startTurnAnimation(1),
ArrowRight: () => this.startTurnAnimation(-1),
KeyQ: () => this.startTurnAnimation(1),
KeyE: () => this.startTurnAnimation(-1),
};
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 { 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 = {
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,
fov: Math.PI / 3, // 60 degrees - good for spooky
wallChar: "#",
floorColor: 0x654321,
floorChar: "f",
ceilingColor: 0x555555,
ceilingChar: "c",
fadeOutColor: 0x555555,
};
export class FirstPersonRenderer {
/**
* @param {AsciiWindow} aWindow the window we render onto.
* @param {TileMap} map
* @param {Texture[]} textures
* @param {FirstPersonRendererOptions} options
* @param {string[]} textureFilenames
*/
constructor(aWindow, map, textures, options) {
/** @constant @readonly @type {TileMap} */
this.map = map;
constructor(aWindow, map, textureFilenames, options) {
const w = 600;
const h = 400;
/** @constant @readonly @type {AsciiWindow} */
this.window = aWindow;
/** @constant @readonly @type {number} */
this.fov = options.fov ?? DefaultRendererOptions.fov;
/** @constant @readonly @type {number} */
this.viewDistance = options.viewDistance ?? DefaultRendererOptions.viewDistance;
/** @constant @readonly @type {Texture[]} */
this.textures = textures;
this.window = aWindow;
this.map = map;
/** @constant @readonly @type {string} */
this.wallChar = options.wallChar ?? DefaultRendererOptions.wallChar;
/** @constant @readonly @type {NRGBA} */
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;
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera((this.fov * 180) / Math.PI, w / h);
this.renderer = new THREE.WebGLRenderer({ antialias: false }); // Do not anti-alias, it could interfere with the conversion to ascii
/**
* Pre-computed colors to use when drawing floors, ceilings and "fadeout"
*
* There is one entry for every screen row.
* Each entry contains a color to use when drawing floors, ceilings, and "fadeout".
*
* @constant @readonly @type {Array<Array<string>>}
*/
this.shades = [];
//
// Fog, Fadeout & Background
//
this.scene.background = new THREE.Color(0);
this.scene.fog = new THREE.Fog(0, 0, this.viewDistance - 1);
/**
* Pre-compute the shades variable
*/
this.computeShades();
//
// Camera
//
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() {
const screenHeight = this.window.height;
const halfScreenHeight = screenHeight / 2;
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
initMap() {
const wallPlanes = [];
const sprites = [];
for (let y = 0; y < screenHeight; y++) {
if (y < minY) {
/** @type {Map<number,Array} */
this.map.forEach((/** @type {Tile} */ tile, /** @type {number} */ x, /** @type {number} y */ y) => {
//
// y is smaller than minY. This means we're painting above
// the walls, i.e. painting the ceiling.
// The closer y is to minY, the farther away this part of the
// ceiling is.
//
// High diff => near
// Low diff => far
//
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.isStartLocation) {
this.camera.position.set(x, y, 0);
this.camera.lookAt(x, y - 1, 0);
this.torch.position.copy(this.camera.position);
console.log("Initial Camera Position:", this.camera.position);
return;
}
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) {
const benchmarkStart = performance.now();
const screenWidth = this.window.width;
this.renderer.render(this.scene, this.camera);
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*/
const coordsChecked = new Map();
this.camera.position.x = posX;
this.camera.position.y = posY;
for (let x = 0; x < screenWidth; x++) {
const angleOffset = (x / screenWidth - 0.5) * this.fov; // in radians
const rayAngle = dirAngle + angleOffset;
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);
this.torch.position.copy(this.camera.position);
this.torch.position.z += 0.25;
this.camera.lookAt(lookAtV);
//
// 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) {
requestAnimationFrame(() => {
const benchmarkStart = performance.now();
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 { AsciiWindow } from "./ascii_window.js";
import { Texture } from "./ascii_textureloader.js";
export class Tile {
/** @type {string} How should this tile be rendered on the minimap.*/
@@ -10,16 +9,16 @@ export class Tile {
minimapColor = "#fff";
/** @type {boolean} Should this be rendered as a wall? */
wall = false;
isWall = false;
/** @type {boolean} is this tile occupied by a sprite? */
sprite = false;
isSprite = false;
/** @type {boolean} Can the player walk here? */
traversable = true;
/** @type {boolean} Is this where they player starts? */
startLocation = false;
isStartLocation = false;
/** @type {boolean} Is this where they player starts? */
textureId = 0;
@@ -34,7 +33,7 @@ export class Tile {
}
get collision() {
return this.wall || this.sprite;
return this.isWall || this.isSprite;
}
}
@@ -45,7 +44,7 @@ export const defaultLegend = Object.freeze({
"": new Tile({
minimapChar: " ",
traversable: true,
wall: false,
isWall: false,
}),
//
@@ -53,14 +52,14 @@ export const defaultLegend = Object.freeze({
" ": new Tile({
minimapChar: " ",
traversable: true,
wall: false,
isWall: false,
}),
//
// Default wall
"#": new Tile({
minimapChar: "#",
traversable: false,
wall: true,
isWall: true,
textureId: 0,
}),
@@ -69,8 +68,8 @@ export const defaultLegend = Object.freeze({
minimapChar: "M",
minimapColor: "#f00",
traversable: false,
wall: false,
sprite: true,
isWall: false,
isSprite: true,
}),
//
@@ -78,15 +77,15 @@ export const defaultLegend = Object.freeze({
"Ω": new Tile({
minimapChar: "#",
traversable: true,
wall: true,
isWall: true,
}),
//
// where the player starts
"S": new Tile({
minimapChar: "S", // "Š",
traversable: true,
wall: false,
startLocation: true,
isWall: false,
isStartLocation: true,
}),
});
@@ -150,7 +149,7 @@ export class TileMap {
/** @constant @readonly @type {Tile[][]} */
this.tiles = tiles;
/** @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() {
@@ -185,7 +184,7 @@ export class TileMap {
return true;
}
return this.tiles[y][x].wall;
return this.tiles[y][x].isWall;
}
findFirst(criteria) {
@@ -242,8 +241,29 @@ export class TileMap {
getAreaAround(x, 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");
}

View File

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

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

@@ -3262,6 +3262,12 @@
"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": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -1,15 +1,31 @@
{
"hash": "a9cc42de",
"hash": "5eac6a41",
"configHash": "86a557ed",
"lockfileHash": "56518f4e",
"browserHash": "f6412460",
"lockfileHash": "3ceab950",
"browserHash": "20105502",
"optimized": {
"sprintf-js": {
"src": "../../sprintf-js/src/sprintf.js",
"file": "sprintf-js.js",
"fileHash": "41b15421",
"fileHash": "089e5f0c",
"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;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
import {
__commonJS
} from "./chunk-BUSYA2B4.js";
// node_modules/sprintf-js/src/sprintf.js
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": {
"figlet": "^1.8.2",
"sprintf-js": "^1.1.3",
"three": "^0.180.0",
"ws": "^8.14.2"
},
"devDependencies": {
@@ -3212,6 +3213,12 @@
"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": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

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

View File

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