391 lines
12 KiB
JavaScript
Executable File
391 lines
12 KiB
JavaScript
Executable File
import { Vector2i, Orientation, RelativeMovement, PI_OVER_TWO } from "./ascii_types.js";
|
|
import { 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 { sprintf } from "sprintf-js";
|
|
|
|
class Player {
|
|
_posV = new Vector2i();
|
|
_directionV = new Vector2i(0, 1);
|
|
|
|
get x() {
|
|
return this._posV.x;
|
|
}
|
|
|
|
get y() {
|
|
return this._posV.y;
|
|
}
|
|
|
|
set x(x) {
|
|
this._posV.x = x | 0;
|
|
}
|
|
|
|
set y(y) {
|
|
this._posV.y = y | 0;
|
|
}
|
|
|
|
get angle() {
|
|
return this._directionV.angle();
|
|
}
|
|
|
|
get orientation() {
|
|
return this._directionV.orientation();
|
|
}
|
|
|
|
set orientation(o) {
|
|
//
|
|
// Sanitize o
|
|
o = ((o | 0) + 4) % 4;
|
|
|
|
if (o === Orientation.EAST) {
|
|
this._directionV = new Vector2i(1, 0);
|
|
return;
|
|
}
|
|
if (o === Orientation.NORTH) {
|
|
this._directionV = new Vector2i(0, 1);
|
|
return;
|
|
}
|
|
if (o === Orientation.WEST) {
|
|
this._directionV = new Vector2i(-1, 0);
|
|
return;
|
|
}
|
|
if (o === Orientation.SOUTH) {
|
|
this._directionV = new Vector2i(0, -1);
|
|
return;
|
|
}
|
|
}
|
|
|
|
withinAABB(maxX, maxY, minX = 0, minY = 0) {
|
|
return this._posV.x >= minX && this._posV.x <= maxX && this._posV.y >= minY && this._posV.y <= maxY;
|
|
}
|
|
}
|
|
|
|
class DungeonCrawler {
|
|
get isAnimating() {
|
|
return (
|
|
this.animation === undefined ||
|
|
this.animation.targetAngle !== undefined ||
|
|
this.animation.targetX !== undefined ||
|
|
this.animation.targetY !== undefined
|
|
);
|
|
}
|
|
|
|
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 */
|
|
handlers: {},
|
|
/** @constant @readonly */
|
|
pressed: {},
|
|
/** @constant @readonly */
|
|
names: {},
|
|
};
|
|
|
|
/** @readonly @type {TileMap} */
|
|
this.map;
|
|
|
|
this.animation = {
|
|
StartTime: undefined,
|
|
StartAngle: undefined,
|
|
StartX: undefined,
|
|
StartY: undefined,
|
|
targetTime: undefined,
|
|
targetAngle: undefined,
|
|
targetX: undefined,
|
|
targetY: undefined,
|
|
};
|
|
|
|
/** @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} */
|
|
renderer: null,
|
|
};
|
|
|
|
/** @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.gameLoop();
|
|
}
|
|
|
|
render(posX = this.player.x, posY = this.player.y, angle = this.player.angle) {
|
|
if (!this.rendering.renderer) {
|
|
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
|
|
angle,
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
const textureUrls = [eobWallUrl1, eobWallUrl2];
|
|
const textureCount = textureUrls.length;
|
|
const textures = [];
|
|
|
|
textureUrls.forEach((url) => {
|
|
Texture.fromSource(url).then((texture) => {
|
|
textures.push(texture);
|
|
|
|
if (textures.length < textureCount) {
|
|
return;
|
|
}
|
|
|
|
this.rendering.renderer = new FirstPersonRenderer(
|
|
this.rendering.view,
|
|
this.map,
|
|
this.rendering.fov,
|
|
this.rendering.maxDepth,
|
|
textures,
|
|
);
|
|
this.render();
|
|
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
|
|
|
console.debug("renderer ready", texture);
|
|
});
|
|
});
|
|
}
|
|
|
|
startTurnAnimation(quarterTurns = 1) {
|
|
if (this.isAnimating) {
|
|
throw new Error("Cannot start an animation while one is already running");
|
|
}
|
|
|
|
if (quarterTurns === 0) {
|
|
return;
|
|
}
|
|
|
|
this.animation = {
|
|
startAngle: this.player.angle,
|
|
startTime: performance.now(),
|
|
startX: this.player.x,
|
|
startY: this.player.y,
|
|
|
|
targetAngle: this.player.angle + PI_OVER_TWO * quarterTurns,
|
|
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
|
|
targetX: this.player.x,
|
|
targetY: this.player.y,
|
|
};
|
|
|
|
//
|
|
this.player._directionV.rotateCCW(quarterTurns);
|
|
this.updateCompass();
|
|
}
|
|
|
|
/** @type {RelativeMovement} Direction the player is going to move */
|
|
startMoveAnimation(direction) {
|
|
//
|
|
if (this.isAnimating) {
|
|
throw new Error("Cannot start an animation while one is already running");
|
|
}
|
|
|
|
//
|
|
// When we move, we move relative to our current viewing direction,
|
|
// so moving LEFT means moving 1 square 90 degrees from our viewing direction vector
|
|
const targetV = this.player._directionV.rotatedCCW(direction | 0).added(this.player._posV);
|
|
|
|
//
|
|
// 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.animation = {
|
|
startAngle: this.player.angle,
|
|
startTime: performance.now(),
|
|
startX: this.player.x,
|
|
startY: this.player.y,
|
|
|
|
targetAngle: this.player.angle,
|
|
targetTime: performance.now() + 700, // MAGIC NUMBER: these animations take .7 seconds
|
|
targetX: targetV.x,
|
|
targetY: targetV.y,
|
|
};
|
|
this.player._posV = targetV;
|
|
|
|
this.updateCompass(); // technically not necessary, but Im anticipating the need + compensating for my bad memory.
|
|
}
|
|
|
|
setupControls() {
|
|
this.keys.pressed = {};
|
|
|
|
this.keys.handlers = {
|
|
KeyA: () => this.startMoveAnimation(RelativeMovement.LEFT),
|
|
KeyD: () => this.startMoveAnimation(RelativeMovement.RIGHT),
|
|
KeyS: () => this.startMoveAnimation(RelativeMovement.BACKWARD),
|
|
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),
|
|
};
|
|
this.keys.names = Object.keys(this.keys.handlers);
|
|
|
|
document.addEventListener(
|
|
"keydown",
|
|
(e) => {
|
|
const id = e.code;
|
|
this.keys.pressed[id] = true;
|
|
},
|
|
true,
|
|
);
|
|
|
|
document.addEventListener(
|
|
"keyup",
|
|
(e) => {
|
|
const id = e.code;
|
|
this.keys.pressed[id] = false;
|
|
},
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
handleAnimation() {
|
|
//
|
|
// Guard: only animate if called for
|
|
if (!this.isAnimating) {
|
|
this.animation = {};
|
|
return;
|
|
}
|
|
|
|
//
|
|
// 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.animation = {};
|
|
this.minimap.draw(this.player.x, this.player.y, this.player.orientation);
|
|
return;
|
|
}
|
|
|
|
const a = this.animation;
|
|
|
|
const nowT = performance.now();
|
|
const animY = a.targetY - a.startY; // how much this animation causes us to move in the y-direction
|
|
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!");
|
|
}
|
|
|
|
// render
|
|
this.render(
|
|
a.startX + animX * deltaT, //
|
|
a.startY + animY * deltaT, //
|
|
a.startAngle + animA * deltaT, //
|
|
);
|
|
}
|
|
|
|
gameLoop() {
|
|
//
|
|
// We're not animating, so we chill out for 50 msec
|
|
if (!this.isAnimating) {
|
|
setTimeout(() => this.gameLoop(), 50); // MAGIC NUMBER
|
|
return;
|
|
}
|
|
|
|
this.handleAnimation();
|
|
|
|
requestAnimationFrame(() => this.gameLoop());
|
|
}
|
|
|
|
updateCompass() {
|
|
//
|
|
//
|
|
// Update the compass
|
|
document.getElementById("compass").textContent = sprintf(
|
|
"%s %s (%d --> %.2f [%dº])",
|
|
this.player._posV,
|
|
Object.keys(Orientation)[this.player.orientation].toLowerCase(),
|
|
this.player.orientation,
|
|
this.player.orientation * PI_OVER_TWO,
|
|
this.player.orientation * 90,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Start the game
|
|
window.game = new DungeonCrawler();
|