This commit is contained in:
Kim Ravn Hansen
2025-09-28 15:03:11 +02:00
parent 95068939af
commit 2053dd3113
12 changed files with 557 additions and 669 deletions

View File

@@ -1,17 +1,23 @@
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
import { FirstPersonRenderer } from "./ascii_first_person_renderer.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";
import eobWallUrl2 from "./eob2.png";
import gnollSpriteUrl from "./gnoll.png";
import { sprintf } from "sprintf-js";
class Player {
/** @protected */
_posV = new Vector2i();
/** @protected */
_directionV = new Vector2i(0, 1);
/** @type {number} number of milliseconds to sleep before next gameLoop. */
delay = 0;
get x() {
return this._posV.x;
}
@@ -75,11 +81,6 @@ class DungeonCrawler {
}
constructor() {
/** @type {number} Number of times per second we poll for controller inputs */
this.pollsPerSec = 60;
/** @type {number} */
this.debounce = 0;
/** @constant @readonly */
this.keys = {
/** @constant @readonly */
@@ -106,84 +107,87 @@ class DungeonCrawler {
/** @readonly */
this.rendering = {
enabled: true,
ticker: 0,
maxDepth: 5,
fov: Math.PI / 3, // 60 degrees, increase maybe?
view: new AsciiWindow(document.getElementById("viewport"), 120, 50),
/** @type {FirstPersonRenderer} */ firstPersonRenderer: null,
/** @type {MiniMapRenderer} */ miniMapRenderer: null,
/** @type {FirstPersonRenderer} */
renderer: null,
firstPersonWindow: new AsciiWindow(document.getElementById("viewport"), 100, 45), // MAGIC CONSTANTS
minimapWindow: new AsciiWindow(document.getElementById("minimap"), 9, 9), // MAGIC CONSTANT
options: DefaultRendererOptions,
};
/** @readonly @type {MiniMapRenderer} */
this.minimap;
/**
* @typedef Player
* @type {object}
* @property {number} x integer. Player's x-coordinate on the grid.
* @property {number} y integer. Player's y-coordinate on the grid.
*/
this.player = new Player();
this.setupControls();
this.loadMap();
this.updateCompass();
this.rendering.view.commitToDOM();
this.render(this.player.x, this.player.y, this.player.orientation * PI_OVER_TWO);
this.renderCompass();
//
// Start the game loop
//
this.gameLoop();
}
render(posX = this.player.x, posY = this.player.y, angle = this.player.angle) {
if (!this.rendering.renderer) {
/**
* Render a first person view of the camera in a given position and orientation.
*
* @param {number} camX the x-coordinate of the camera (in map coordinates)
* @param {number} camY the y-coordinate of the camera (in map coordinates)
* @param {number} angle the orientation of the camera in radians around the unit circle.
*/
render(camX = this.player.x, camY = this.player.y, angle = this.player.angle) {
if (!this.rendering.firstPersonRenderer) {
console.log("Renderer not ready yet");
return;
}
this.rendering.renderer.renderFrame(
posX + 0.5, // add .5 to get camera into center of cell
posY + 0.5, // add .5 to get camera into center of cell
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
angle,
);
}
renderMinimap() {
this.rendering.miniMapRenderer.draw(this.player.x, this.player.y, this.player.orientation);
}
loadMap() {
const mapString = document.getElementById("mapText").value;
this.map = TileMap.fromText(mapString);
this.player._posV = this.map.findFirst({ startLocation: true });
if (!this.player._posV) {
throw new Error("Could not find a start location for the player");
}
console.log(this.map.getAreaAround(this.player.x, this.player.y, 5).toString());
const minimapElement = document.getElementById("minimap");
const minimapWindow = new AsciiWindow(minimapElement, 9, 9); // MAGIC NUMBERS: width and height of the minimap
this.minimap = new MiniMapRenderer(minimapWindow, this.map);
this.rendering.miniMapRenderer = new MiniMapRenderer(this.rendering.minimapWindow, this.map);
const textureUrls = [eobWallUrl1, eobWallUrl2];
const textureCount = textureUrls.length;
const textures = [];
const textureUrls = [eobWallUrl1, gnollSpriteUrl];
const textures = new Array(textureUrls.length).fill();
let textureLoadCount = 0;
textureUrls.forEach((url) => {
textureUrls.forEach((url, textureId) => {
Texture.fromSource(url).then((texture) => {
textures.push(texture);
textures[textureId] = texture;
if (textures.length < textureCount) {
if (textureLoadCount > textureUrls.length) {
return;
}
this.rendering.renderer = new FirstPersonRenderer(
this.rendering.view,
textureLoadCount++;
this.rendering.firstPersonRenderer = new FirstPersonRenderer(
this.rendering.firstPersonWindow,
this.map,
this.rendering.fov,
this.rendering.maxDepth,
textures,
this.rendering.options,
);
this.render();
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
this.renderMinimap();
console.debug("renderer ready", texture);
});
@@ -213,7 +217,6 @@ class DungeonCrawler {
//
this.player._directionV.rotateCCW(quarterTurns);
this.updateCompass();
}
/** @type {RelativeMovement} Direction the player is going to move */
@@ -231,14 +234,14 @@ class DungeonCrawler {
//
// We cant move into walls
if (this.map.isWall(targetV.x, targetV.y)) {
this.debounce = (this.pollsPerSec / 5) | 0;
console.info(
"bumped into wall at %s (mypos: %s), direction=%d",
targetV,
this.player._posV,
this.player.angle,
);
return;
this.delay += 250; // MAGIC NUMBER: Pause for a tenth of a second after hitting a wall
return false;
}
this.animation = {
@@ -254,7 +257,7 @@ class DungeonCrawler {
};
this.player._posV = targetV;
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
return true;
}
setupControls() {
@@ -291,50 +294,39 @@ class DungeonCrawler {
},
true,
);
const ticks = Math.round(1000 / this.pollsPerSec);
this.keys.interval = setInterval(() => {
this.handleKeyboardInput();
}, ticks);
}
handleKeyboardInput() {
if (this.debounce > 0) {
this.debounce--;
return;
}
if (this.isAnimating) {
return;
}
//
// Check each key we can handle.
for (let key of this.keys.names) {
if (this.keys.pressed[key]) {
this.debounce = Math.floor(this.animation.fps * this.animation.animationDuration) - 1;
const keyHandler = this.keys.handlers[key];
keyHandler();
return;
return keyHandler();
}
}
return false;
}
/**
* @returns {boolean} true if an animation is in progress
*/
handleAnimation() {
//
// Guard: only animate if called for
if (!this.isAnimating) {
this.animation = {};
return;
return false;
}
//
// Guard, stop animation if it took too long
// Guard: stop animation if it took too long
if (this.animation.targetTime <= performance.now()) {
this.render(this.player.x, this.player.y, this.player.angle);
this.renderMinimap();
this.renderCompass();
this.animation = {};
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
return;
return false;
}
const a = this.animation;
@@ -344,44 +336,68 @@ class DungeonCrawler {
const animX = a.targetX - a.startX; // how much this animation causes us to move in the x-direction
const animA = a.targetAngle - a.startAngle; // how much this animation causes us to rotate in total
const animT = a.targetTime - a.startTime; // how long (in ms) this animation is supposed to take.
const deltaT = (nowT - a.startTime) / animT;
if (deltaT > 1) {
throw new Error("Not supposed to happen!");
}
const progress = Math.min((nowT - a.startTime) / animT, 1);
// render
this.render(
a.startX + animX * deltaT, //
a.startY + animY * deltaT, //
a.startAngle + animA * deltaT, //
a.startX + animX * progress, //
a.startY + animY * progress, //
a.startAngle + animA * progress, //
);
return true;
}
gameLoop() {
//
// We're not animating, so we chill out for 50 msec
if (!this.isAnimating) {
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
// Has something in the game logic told us to chill out?
//
if (this.delay) {
setTimeout(() => this.gameLoop(), this.delay);
this.delay = 0;
return;
}
this.handleAnimation();
//
// Are we animating ?
// Then render a single frame, and then chill out for 20ms.
// Do not process keyboard input while animating
//
if (this.handleAnimation()) {
setTimeout(() => this.gameLoop(), 20);
return;
}
requestAnimationFrame(() => this.gameLoop());
//
// Has a key been pressed that we need to react to?
// Then queue up a new gameLoop call to be executed
// as soon as possible.
//
// NOTE: this happens inside a microtask to ensure
// that the call stack does not get too big and that
// each single call to gameLoop does not take too
// long
//
if (this.handleKeyboardInput()) {
queueMicrotask(() => this.gameLoop());
return;
}
//
// Are we idling?
// Then only check for new events every 20ms to use less power
//
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
}
updateCompass() {
renderCompass() {
//
//
// Update the compass
document.getElementById("compass").textContent = sprintf(
"%s %s (%d --> %.2f [%dº])",
document.getElementById("compass").innerHTML = sprintf(
"<div>%s</div><div>%s</div>",
this.player._posV,
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
this.player.orientation,
this.player.orientation * PI_OVER_TWO,
this.player.orientation * 90,
);
}
}