diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b512c09
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+node_modules
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 60d7219..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2024 Abbas Mahdavi
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/README.md b/README.md
index 5aa6ce0..f235fe7 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,9 @@
# RayCastingGame
+
This project is a basic implementation of a ray casting game using TypeScript and HTML5 Canvas. It features a grid-based environment where rays are cast from a point and interact with obstacles on the grid.
+
+Guide by Tsoding Daily and Lode's Computer Graphics Tutorial : https://lodev.org/cgtutor/raycasting.html
+
+#Build Index.js
+npm install
+npm run build
diff --git a/assets/images/sad.png b/assets/images/sad.png
new file mode 100644
index 0000000..12a794f
Binary files /dev/null and b/assets/images/sad.png differ
diff --git a/assets/textures/wall.png b/assets/textures/wall.png
new file mode 100644
index 0000000..a180293
Binary files /dev/null and b/assets/textures/wall.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..34fa024
--- /dev/null
+++ b/index.html
@@ -0,0 +1,18 @@
+
+
+
+ Ray Casting Game
+
+
+
+
+
+
+
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..378116f
--- /dev/null
+++ b/index.js
@@ -0,0 +1,389 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+const EPS = 1e-6;
+const NEAR_CLIPPING_PLANE = 0.25;
+const FAR_CLIPPING_PLANE = 10.0;
+const FOV = Math.PI * 0.5;
+const SCREEN_WIDTH = 300;
+const PLAYER_STEP_LEN = 0.5;
+const PLAYER_SPEED = 2;
+class Vector2 {
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+ static zero() {
+ return new Vector2(0, 0);
+ }
+ static fromAngle(angle) {
+ return new Vector2(Math.cos(angle), Math.sin(angle));
+ }
+ add(that) {
+ return new Vector2(this.x + that.x, this.y + that.y);
+ }
+ sub(that) {
+ return new Vector2(this.x - that.x, this.y - that.y);
+ }
+ div(that) {
+ return new Vector2(this.x / that.x, this.y / that.y);
+ }
+ mul(that) {
+ return new Vector2(this.x * that.x, this.y * that.y);
+ }
+ length() {
+ return Math.sqrt(this.x * this.x + this.y * this.y);
+ }
+ sqrLength() {
+ return this.x * this.x + this.y * this.y;
+ }
+ norm() {
+ const l = this.length();
+ if (l === 0)
+ return new Vector2(0, 0);
+ return new Vector2(this.x / l, this.y / l);
+ }
+ scale(value) {
+ return new Vector2(this.x * value, this.y * value);
+ }
+ rot90() {
+ return new Vector2(-this.y, this.x);
+ }
+ sqrDistanceTo(that) {
+ return that.sub(this).sqrLength();
+ }
+ lerp(that, t) {
+ return that.sub(this).scale(t).add(this);
+ }
+ dot(that) {
+ return this.x * that.x + this.y * that.y;
+ }
+ array() {
+ return [this.x, this.y];
+ }
+}
+class Color {
+ constructor(r, g, b, a) {
+ this.r = r;
+ this.g = g;
+ this.b = b;
+ this.a = a;
+ }
+ static red() {
+ return new Color(1, 0, 0, 1);
+ }
+ static green() {
+ return new Color(0, 1, 0, 1);
+ }
+ static blue() {
+ return new Color(0, 0, 1, 1);
+ }
+ static yellow() {
+ return new Color(1, 1, 0, 1);
+ }
+ static purple() {
+ return new Color(1, 0, 1, 1);
+ }
+ static cyan() {
+ return new Color(0, 1, 1, 1);
+ }
+ brightness(factor) {
+ return new Color(factor * this.r, factor * this.g, factor * this.b, this.a);
+ }
+ toStyle() {
+ return (`rgba(` +
+ `${Math.floor(this.r * 255)}, ` +
+ `${Math.floor(this.g * 255)}, ` +
+ `${Math.floor(this.b * 255)}, ` +
+ `${this.a})`);
+ }
+}
+class Player {
+ constructor(position, direction) {
+ this.position = position;
+ this.direction = direction;
+ }
+ fovRange() {
+ const l = Math.tan(FOV * 0.5) * NEAR_CLIPPING_PLANE;
+ const p = this.position.add(Vector2.fromAngle(this.direction).scale(NEAR_CLIPPING_PLANE));
+ const p1 = p.sub(p.sub(this.position).rot90().norm().scale(l));
+ const p2 = p.add(p.sub(this.position).rot90().norm().scale(l));
+ return [p1, p2];
+ }
+}
+function drawLine(ctx, p1, p2) {
+ ctx.beginPath();
+ ctx.moveTo(...p1.array());
+ ctx.lineTo(...p2.array());
+ ctx.stroke();
+}
+function drawCircle(ctx, center, radius) {
+ ctx.beginPath();
+ ctx.arc(...center.array(), radius, 0, 2 * Math.PI);
+ ctx.fill();
+}
+function canvasSize(ctx) {
+ return new Vector2(ctx.canvas.width, ctx.canvas.height);
+}
+function sceneSize(scene) {
+ const y = scene.length;
+ let x = Number.MIN_VALUE;
+ for (let row of scene) {
+ x = Math.max(x, row.length);
+ }
+ return new Vector2(x, y);
+}
+function snap(x, dx) {
+ if (dx > 0)
+ return Math.ceil(x + Math.sign(dx) * EPS);
+ if (dx < 0)
+ return Math.floor(x + Math.sign(dx) * EPS);
+ return x;
+}
+function hittingCell(p1, p2) {
+ const d = p2.sub(p1);
+ return new Vector2(Math.floor(p2.x + Math.sign(d.x) * EPS), Math.floor(p2.y + Math.sign(d.y) * EPS));
+}
+function rayStep(p1, p2) {
+ /* slope equation
+ p1 = (x1, y1)
+ p2 = (x2, y2)
+
+ y1 = m*x1 + c
+ y2 = m*x2 + c
+
+ dy = y2 - y1
+ dx = x2 - x1
+ m = dy / dx
+ c = y1 - k*x1
+
+ */
+ let p3 = p2;
+ const d = p2.sub(p1);
+ if (d.x != 0) {
+ const m = d.y / d.x;
+ const c = p1.y - m * p1.x;
+ const x3 = snap(p2.x, d.x);
+ const y3 = m * x3 + c;
+ {
+ const y3 = x3 * m + c;
+ p3 = new Vector2(x3, y3);
+ }
+ if (m !== 0) {
+ const y3 = snap(p2.y, d.y);
+ const x3 = (y3 - c) / m;
+ const p3t = new Vector2(x3, y3);
+ if (p2.sqrDistanceTo(p3t) < p2.sqrDistanceTo(p3)) {
+ p3 = p3t;
+ }
+ }
+ }
+ else {
+ const y3 = snap(p2.y, d.y);
+ const x3 = p2.x;
+ p3 = new Vector2(x3, y3);
+ }
+ return p3;
+}
+function renderMinimap(ctx, player, position, size, scene) {
+ ctx.save();
+ const gridSize = sceneSize(scene);
+ ctx.translate(...position.array());
+ ctx.scale(...size.div(gridSize).array());
+ ctx.fillStyle = "#181818";
+ ctx.fillRect(0, 0, ...gridSize.array());
+ ctx.lineWidth = 0.06;
+ for (let y = 0; y < gridSize.y; ++y) {
+ for (let x = 0; x < gridSize.x; ++x) {
+ const cell = scene[y][x];
+ if (cell instanceof Color) {
+ ctx.fillStyle = cell.toStyle();
+ ctx.fillRect(x, y, 1, 1);
+ }
+ else if (cell instanceof HTMLImageElement) {
+ ctx.drawImage(cell, x, y, 1, 1);
+ }
+ }
+ }
+ ctx.strokeStyle = "#303030";
+ for (let x = 0; x <= gridSize.x; ++x) {
+ drawLine(ctx, new Vector2(x, 0), new Vector2(x, gridSize.y));
+ }
+ for (let y = 0; y <= gridSize.y; ++y) {
+ drawLine(ctx, new Vector2(0, y), new Vector2(gridSize.x, y));
+ }
+ ctx.fillStyle = "lime";
+ drawCircle(ctx, player.position, 0.2);
+ const [p1, p2] = player.fovRange();
+ ctx.strokeStyle = "lime";
+ drawLine(ctx, p1, p2);
+ drawLine(ctx, player.position, p1);
+ drawLine(ctx, player.position, p2);
+ ctx.restore();
+}
+function insideScene(scene, p) {
+ const size = sceneSize(scene);
+ return 0 <= p.x && p.x < size.x && 0 <= p.y && p.y < size.y;
+}
+function castRay(scene, p1, p2) {
+ let start = p1;
+ while (start.sqrDistanceTo(p1) < FAR_CLIPPING_PLANE * FAR_CLIPPING_PLANE) {
+ const c = hittingCell(p1, p2);
+ if (insideScene(scene, c) && scene[c.y][c.x] !== null)
+ break;
+ const p3 = rayStep(p1, p2);
+ p1 = p2;
+ p2 = p3;
+ }
+ return p2;
+}
+function renderScene(ctx, player, scene) {
+ const [r1, r2] = player.fovRange();
+ const stripWidth = Math.ceil(ctx.canvas.width / SCREEN_WIDTH);
+ for (let x = 0; x < SCREEN_WIDTH; ++x) {
+ const p = castRay(scene, player.position, r1.lerp(r2, x / SCREEN_WIDTH));
+ const c = hittingCell(player.position, p);
+ if (insideScene(scene, c)) {
+ const cell = scene[c.y][c.x];
+ if (cell instanceof Color) {
+ const v = p.sub(player.position);
+ const d = Vector2.fromAngle(player.direction);
+ const stripHeight = ctx.canvas.height / v.dot(d);
+ ctx.fillStyle = cell.brightness(1 / v.dot(d)).toStyle();
+ ctx.fillRect(x * stripWidth, (ctx.canvas.height - stripHeight) * 0.5, stripWidth, stripHeight);
+ }
+ else if (cell instanceof HTMLImageElement) {
+ const v = p.sub(player.position);
+ const d = Vector2.fromAngle(player.direction);
+ const stripHeight = ctx.canvas.height / v.dot(d);
+ let u = 0;
+ const t = p.sub(c);
+ if ((Math.abs(t.x) < EPS || Math.abs(t.x - 1) < EPS) && t.y > 0) {
+ u = t.y;
+ }
+ else {
+ u = t.x;
+ }
+ ctx.drawImage(cell, Math.floor(u * cell.width), 0, 1, cell.height, x * stripWidth, (ctx.canvas.height - stripHeight) * 0.5, stripWidth, stripHeight);
+ ctx.fillStyle = new Color(0, 0, 0, 1 - 1 / v.dot(d)).toStyle();
+ ctx.fillRect(x * stripWidth, (ctx.canvas.height - stripHeight * 1.01) * 0.5, stripWidth, stripHeight * 1.01);
+ }
+ }
+ }
+}
+function renderGame(ctx, player, scene) {
+ const minimapPosition = Vector2.zero().add(canvasSize(ctx).scale(0.03));
+ const cellSize = ctx.canvas.width * 0.025;
+ const minimapSize = sceneSize(scene).scale(cellSize);
+ ctx.fillStyle = "#181818";
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ renderScene(ctx, player, scene);
+ renderMinimap(ctx, player, minimapPosition, minimapSize, scene);
+}
+function loadImageData(url) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const image = new Image();
+ image.src = url;
+ return new Promise((resolve, reject) => {
+ image.onload = () => resolve(image);
+ image.onerror = reject;
+ });
+ });
+}
+(() => __awaiter(void 0, void 0, void 0, function* () {
+ const game = document.getElementById("game");
+ if (game === null)
+ throw new Error("Can't find game canvas!");
+ const factor = 80;
+ game.width = 16 * factor;
+ game.height = 9 * factor;
+ const ctx = game.getContext("2d");
+ if (ctx === null)
+ throw new Error("Not supported!");
+ const face = yield loadImageData("assets/textures/wall.png").catch(() => Color.purple());
+ let scene = [
+ [null, null, null, face, null, null, null, null, null],
+ [null, null, null, face, null, null, null, null, null],
+ [null, face, face, face, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ ];
+ let player = new Player(sceneSize(scene).mul(new Vector2(0.65, 0.65)), Math.PI * 1.25);
+ let movingForward = false;
+ let movingBackward = false;
+ let turningLeft = false;
+ let turningRight = false;
+ window.addEventListener("keydown", (e) => {
+ if (!e.repeat) {
+ switch (e.code) {
+ case "KeyW":
+ movingForward = true;
+ break;
+ case "KeyS":
+ movingBackward = true;
+ break;
+ case "KeyA":
+ turningLeft = true;
+ break;
+ case "KeyD":
+ turningRight = true;
+ break;
+ }
+ }
+ });
+ window.addEventListener("keyup", (e) => {
+ if (!e.repeat) {
+ switch (e.code) {
+ case "KeyW":
+ movingForward = false;
+ break;
+ case "KeyS":
+ movingBackward = false;
+ break;
+ case "KeyA":
+ turningLeft = false;
+ break;
+ case "KeyD":
+ turningRight = false;
+ break;
+ }
+ }
+ });
+ let prevTimestamp = 0;
+ const frame = (timestamp) => {
+ const deltaTime = (timestamp - prevTimestamp) / 1000;
+ prevTimestamp = timestamp;
+ let velocity = Vector2.zero();
+ let angularVelocity = 0.0;
+ if (movingForward) {
+ velocity = velocity.add(Vector2.fromAngle(player.direction).scale(PLAYER_SPEED));
+ }
+ if (movingBackward) {
+ velocity = velocity.sub(Vector2.fromAngle(player.direction).scale(PLAYER_SPEED));
+ }
+ if (turningLeft) {
+ angularVelocity -= Math.PI;
+ }
+ if (turningRight) {
+ angularVelocity += Math.PI;
+ }
+ player.direction = player.direction + angularVelocity * deltaTime;
+ player.position = player.position.add(velocity.scale(deltaTime));
+ renderGame(ctx, player, scene);
+ window.requestAnimationFrame(frame);
+ };
+ window.requestAnimationFrame((timestamp) => {
+ prevTimestamp = timestamp;
+ window.requestAnimationFrame(frame);
+ });
+}))();
diff --git a/index.ts b/index.ts
new file mode 100644
index 0000000..f95fbf3
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,475 @@
+const EPS = 1e-6;
+const NEAR_CLIPPING_PLANE = 0.25;
+const FAR_CLIPPING_PLANE = 10.0;
+const FOV = Math.PI * 0.5;
+const SCREEN_WIDTH = 300;
+const PLAYER_STEP_LEN = 0.5;
+const PLAYER_SPEED = 2;
+
+class Vector2 {
+ x: number;
+ y: number;
+ constructor(x: number, y: number) {
+ this.x = x;
+ this.y = y;
+ }
+ static zero(): Vector2 {
+ return new Vector2(0, 0);
+ }
+ static fromAngle(angle: number): Vector2 {
+ return new Vector2(Math.cos(angle), Math.sin(angle));
+ }
+ add(that: Vector2): Vector2 {
+ return new Vector2(this.x + that.x, this.y + that.y);
+ }
+ sub(that: Vector2): Vector2 {
+ return new Vector2(this.x - that.x, this.y - that.y);
+ }
+ div(that: Vector2): Vector2 {
+ return new Vector2(this.x / that.x, this.y / that.y);
+ }
+ mul(that: Vector2): Vector2 {
+ return new Vector2(this.x * that.x, this.y * that.y);
+ }
+ length(): number {
+ return Math.sqrt(this.x * this.x + this.y * this.y);
+ }
+ sqrLength(): number {
+ return this.x * this.x + this.y * this.y;
+ }
+ norm(): Vector2 {
+ const l = this.length();
+ if (l === 0) return new Vector2(0, 0);
+ return new Vector2(this.x / l, this.y / l);
+ }
+ scale(value: number): Vector2 {
+ return new Vector2(this.x * value, this.y * value);
+ }
+ rot90(): Vector2 {
+ return new Vector2(-this.y, this.x);
+ }
+ sqrDistanceTo(that: Vector2): number {
+ return that.sub(this).sqrLength();
+ }
+ lerp(that: Vector2, t: number): Vector2 {
+ return that.sub(this).scale(t).add(this);
+ }
+ dot(that: Vector2): number {
+ return this.x * that.x + this.y * that.y;
+ }
+ array(): [number, number] {
+ return [this.x, this.y];
+ }
+}
+
+class Color {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+ constructor(r: number, g: number, b: number, a: number) {
+ this.r = r;
+ this.g = g;
+ this.b = b;
+ this.a = a;
+ }
+ static red(): Color {
+ return new Color(1, 0, 0, 1);
+ }
+ static green(): Color {
+ return new Color(0, 1, 0, 1);
+ }
+ static blue(): Color {
+ return new Color(0, 0, 1, 1);
+ }
+ static yellow(): Color {
+ return new Color(1, 1, 0, 1);
+ }
+ static purple(): Color {
+ return new Color(1, 0, 1, 1);
+ }
+ static cyan(): Color {
+ return new Color(0, 1, 1, 1);
+ }
+ brightness(factor: number): Color {
+ return new Color(factor * this.r, factor * this.g, factor * this.b, this.a);
+ }
+ toStyle(): string {
+ return (
+ `rgba(` +
+ `${Math.floor(this.r * 255)}, ` +
+ `${Math.floor(this.g * 255)}, ` +
+ `${Math.floor(this.b * 255)}, ` +
+ `${this.a})`
+ );
+ }
+}
+
+class Player {
+ position: Vector2;
+ direction: number;
+
+ constructor(position: Vector2, direction: number) {
+ this.position = position;
+ this.direction = direction;
+ }
+
+ fovRange(): [Vector2, Vector2] {
+ const l = Math.tan(FOV * 0.5) * NEAR_CLIPPING_PLANE;
+ const p = this.position.add(
+ Vector2.fromAngle(this.direction).scale(NEAR_CLIPPING_PLANE)
+ );
+
+ const p1 = p.sub(p.sub(this.position).rot90().norm().scale(l));
+ const p2 = p.add(p.sub(this.position).rot90().norm().scale(l));
+
+ return [p1, p2];
+ }
+}
+
+function drawLine(ctx: CanvasRenderingContext2D, p1: Vector2, p2: Vector2) {
+ ctx.beginPath();
+ ctx.moveTo(...p1.array());
+ ctx.lineTo(...p2.array());
+ ctx.stroke();
+}
+
+function drawCircle(
+ ctx: CanvasRenderingContext2D,
+ center: Vector2,
+ radius: number
+) {
+ ctx.beginPath();
+ ctx.arc(...center.array(), radius, 0, 2 * Math.PI);
+ ctx.fill();
+}
+
+function canvasSize(ctx: CanvasRenderingContext2D): Vector2 {
+ return new Vector2(ctx.canvas.width, ctx.canvas.height);
+}
+
+function sceneSize(scene: Scene): Vector2 {
+ const y = scene.length;
+ let x = Number.MIN_VALUE;
+ for (let row of scene) {
+ x = Math.max(x, row.length);
+ }
+ return new Vector2(x, y);
+}
+
+function snap(x: number, dx: number): number {
+ if (dx > 0) return Math.ceil(x + Math.sign(dx) * EPS);
+ if (dx < 0) return Math.floor(x + Math.sign(dx) * EPS);
+ return x;
+}
+
+function hittingCell(p1: Vector2, p2: Vector2): Vector2 {
+ const d = p2.sub(p1);
+ return new Vector2(
+ Math.floor(p2.x + Math.sign(d.x) * EPS),
+ Math.floor(p2.y + Math.sign(d.y) * EPS)
+ );
+}
+
+function rayStep(p1: Vector2, p2: Vector2): Vector2 {
+ /* slope equation
+ p1 = (x1, y1)
+ p2 = (x2, y2)
+
+ y1 = m*x1 + c
+ y2 = m*x2 + c
+
+ dy = y2 - y1
+ dx = x2 - x1
+ m = dy / dx
+ c = y1 - k*x1
+
+ */
+ let p3 = p2;
+ const d = p2.sub(p1);
+ if (d.x != 0) {
+ const m = d.y / d.x;
+ const c = p1.y - m * p1.x;
+ const x3 = snap(p2.x, d.x);
+ const y3 = m * x3 + c;
+
+ {
+ const y3 = x3 * m + c;
+ p3 = new Vector2(x3, y3);
+ }
+
+ if (m !== 0) {
+ const y3 = snap(p2.y, d.y);
+ const x3 = (y3 - c) / m;
+ const p3t = new Vector2(x3, y3);
+ if (p2.sqrDistanceTo(p3t) < p2.sqrDistanceTo(p3)) {
+ p3 = p3t;
+ }
+ }
+ } else {
+ const y3 = snap(p2.y, d.y);
+ const x3 = p2.x;
+ p3 = new Vector2(x3, y3);
+ }
+
+ return p3;
+}
+
+function renderMinimap(
+ ctx: CanvasRenderingContext2D,
+ player: Player,
+ position: Vector2,
+ size: Vector2,
+ scene: Scene
+) {
+ ctx.save();
+
+ const gridSize = sceneSize(scene);
+
+ ctx.translate(...position.array());
+ ctx.scale(...size.div(gridSize).array());
+
+ ctx.fillStyle = "#181818";
+ ctx.fillRect(0, 0, ...gridSize.array());
+
+ ctx.lineWidth = 0.06;
+ for (let y = 0; y < gridSize.y; ++y) {
+ for (let x = 0; x < gridSize.x; ++x) {
+ const cell = scene[y][x];
+ if (cell instanceof Color) {
+ ctx.fillStyle = cell.toStyle();
+ ctx.fillRect(x, y, 1, 1);
+ } else if (cell instanceof HTMLImageElement) {
+ ctx.drawImage(cell, x, y, 1, 1);
+ }
+ }
+ }
+
+ ctx.strokeStyle = "#303030";
+
+ for (let x = 0; x <= gridSize.x; ++x) {
+ drawLine(ctx, new Vector2(x, 0), new Vector2(x, gridSize.y));
+ }
+ for (let y = 0; y <= gridSize.y; ++y) {
+ drawLine(ctx, new Vector2(0, y), new Vector2(gridSize.x, y));
+ }
+
+ ctx.fillStyle = "lime";
+ drawCircle(ctx, player.position, 0.2);
+
+ const [p1, p2] = player.fovRange();
+
+ ctx.strokeStyle = "lime";
+ drawLine(ctx, p1, p2);
+ drawLine(ctx, player.position, p1);
+ drawLine(ctx, player.position, p2);
+
+ ctx.restore();
+}
+
+type Scene = Array>;
+
+function insideScene(scene: Scene, p: Vector2): boolean {
+ const size = sceneSize(scene);
+ return 0 <= p.x && p.x < size.x && 0 <= p.y && p.y < size.y;
+}
+
+function castRay(scene: Scene, p1: Vector2, p2: Vector2): Vector2 {
+ let start = p1;
+ while (start.sqrDistanceTo(p1) < FAR_CLIPPING_PLANE * FAR_CLIPPING_PLANE) {
+ const c = hittingCell(p1, p2);
+ if (insideScene(scene, c) && scene[c.y][c.x] !== null) break;
+ const p3 = rayStep(p1, p2);
+ p1 = p2;
+ p2 = p3;
+ }
+ return p2;
+}
+
+function renderScene(
+ ctx: CanvasRenderingContext2D,
+ player: Player,
+ scene: Scene
+) {
+ const [r1, r2] = player.fovRange();
+ const stripWidth = Math.ceil(ctx.canvas.width / SCREEN_WIDTH);
+
+ for (let x = 0; x < SCREEN_WIDTH; ++x) {
+ const p = castRay(scene, player.position, r1.lerp(r2, x / SCREEN_WIDTH));
+ const c = hittingCell(player.position, p);
+ if (insideScene(scene, c)) {
+ const cell = scene[c.y][c.x];
+ if (cell instanceof Color) {
+ const v = p.sub(player.position);
+ const d = Vector2.fromAngle(player.direction);
+ const stripHeight = ctx.canvas.height / v.dot(d);
+ ctx.fillStyle = cell.brightness(1 / v.dot(d)).toStyle();
+ ctx.fillRect(
+ x * stripWidth,
+ (ctx.canvas.height - stripHeight) * 0.5,
+ stripWidth,
+ stripHeight
+ );
+ } else if (cell instanceof HTMLImageElement) {
+ const v = p.sub(player.position);
+ const d = Vector2.fromAngle(player.direction);
+ const stripHeight = ctx.canvas.height / v.dot(d);
+
+ let u = 0;
+ const t = p.sub(c);
+ if ((Math.abs(t.x) < EPS || Math.abs(t.x - 1) < EPS) && t.y > 0) {
+ u = t.y;
+ } else {
+ u = t.x;
+ }
+
+ ctx.drawImage(
+ cell,
+ Math.floor(u * cell.width),
+ 0,
+ 1,
+ cell.height,
+ x * stripWidth,
+ (ctx.canvas.height - stripHeight) * 0.5,
+ stripWidth,
+ stripHeight
+ );
+ ctx.fillStyle = new Color(0, 0, 0, 1 - 1 / v.dot(d)).toStyle();
+ ctx.fillRect(
+ x * stripWidth,
+ (ctx.canvas.height - stripHeight * 1.01) * 0.5,
+ stripWidth,
+ stripHeight * 1.01
+ );
+ }
+ }
+ }
+}
+
+function renderGame(
+ ctx: CanvasRenderingContext2D,
+ player: Player,
+ scene: Scene
+) {
+ const minimapPosition = Vector2.zero().add(canvasSize(ctx).scale(0.03));
+ const cellSize = ctx.canvas.width * 0.025;
+ const minimapSize = sceneSize(scene).scale(cellSize);
+
+ ctx.fillStyle = "#181818";
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ renderScene(ctx, player, scene);
+ renderMinimap(ctx, player, minimapPosition, minimapSize, scene);
+}
+
+async function loadImageData(url: string): Promise {
+ const image = new Image();
+ image.src = url;
+ return new Promise((resolve, reject) => {
+ image.onload = () => resolve(image);
+ image.onerror = reject;
+ });
+}
+
+(async () => {
+ const game = document.getElementById("game") as HTMLCanvasElement | null;
+ if (game === null) throw new Error("Can't find game canvas!");
+ const factor = 80;
+ game.width = 16 * factor;
+ game.height = 9 * factor;
+
+ const ctx = game.getContext("2d");
+ if (ctx === null) throw new Error("Not supported!");
+
+ const face = await loadImageData("assets/textures/wall.png").catch(() =>
+ Color.purple()
+ );
+
+ let scene = [
+ [null, null, null, face, null, null, null, null, null],
+ [null, null, null, face, null, null, null, null, null],
+ [null, face, face, face, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ [null, null, null, null, null, null, null, null, null],
+ ];
+ let player = new Player(
+ sceneSize(scene).mul(new Vector2(0.65, 0.65)),
+ Math.PI * 1.25
+ );
+
+ let movingForward = false;
+ let movingBackward = false;
+ let turningLeft = false;
+ let turningRight = false;
+
+ window.addEventListener("keydown", (e) => {
+ if (!e.repeat) {
+ switch (e.code) {
+ case "KeyW":
+ movingForward = true;
+ break;
+ case "KeyS":
+ movingBackward = true;
+ break;
+ case "KeyA":
+ turningLeft = true;
+ break;
+ case "KeyD":
+ turningRight = true;
+ break;
+ }
+ }
+ });
+ window.addEventListener("keyup", (e) => {
+ if (!e.repeat) {
+ switch (e.code) {
+ case "KeyW":
+ movingForward = false;
+ break;
+ case "KeyS":
+ movingBackward = false;
+ break;
+ case "KeyA":
+ turningLeft = false;
+ break;
+ case "KeyD":
+ turningRight = false;
+ break;
+ }
+ }
+ });
+
+ let prevTimestamp = 0;
+ const frame = (timestamp: number) => {
+ const deltaTime = (timestamp - prevTimestamp) / 1000;
+ prevTimestamp = timestamp;
+ let velocity = Vector2.zero();
+ let angularVelocity = 0.0;
+ if (movingForward) {
+ velocity = velocity.add(
+ Vector2.fromAngle(player.direction).scale(PLAYER_SPEED)
+ );
+ }
+ if (movingBackward) {
+ velocity = velocity.sub(
+ Vector2.fromAngle(player.direction).scale(PLAYER_SPEED)
+ );
+ }
+ if (turningLeft) {
+ angularVelocity -= Math.PI;
+ }
+ if (turningRight) {
+ angularVelocity += Math.PI;
+ }
+ player.direction = player.direction + angularVelocity * deltaTime;
+ player.position = player.position.add(velocity.scale(deltaTime));
+ renderGame(ctx, player, scene);
+ window.requestAnimationFrame(frame);
+ };
+ window.requestAnimationFrame((timestamp) => {
+ prevTimestamp = timestamp;
+ window.requestAnimationFrame(frame);
+ });
+})();
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..1d5bd04
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,30 @@
+{
+ "name": "raycastinggame",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "raycastinggame",
+ "version": "1.0.0",
+ "license": "MIT",
+ "devDependencies": {
+ "typescript": "^5.5.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
+ "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e9665ce
--- /dev/null
+++ b/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "raycastinggame",
+ "version": "1.0.0",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "watch": "node server.js"
+ },
+ "author": "",
+ "license": "MIT",
+ "description": "",
+ "devDependencies": {
+ "typescript": "^5.5.3"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..72608e5
--- /dev/null
+++ b/server.js
@@ -0,0 +1,18 @@
+const { spawn } = require("child_process");
+
+function cmd(program, args) {
+ const spawnOptions = { shell: true };
+ console.log("CMD:", program, args.flat(), spawnOptions);
+ const p = spawn(program, args.flat(), spawnOptions);
+ p.stdout.on("data", (data) => process.stdout.write(data));
+ p.stderr.on("data", (data) => process.stderr.write(data));
+ p.on("close", (code) => {
+ if (code !== 0) {
+ console.error(program, args, "exited with", code);
+ }
+ });
+ return p;
+}
+
+cmd("tsc", ["-w"]);
+cmd("http-server", ["-p", "3000", "-a", "127.0.0.1", "-s", "-c-1"]);
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..8bb6097
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,108 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "commonjs", /* Specify what module code is generated. */
+ // "rootDir": "./", /* Specify the root folder within your source files. */
+ // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+ /* JavaScript Support */
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ // "outDir": "./", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "noEmit": true, /* Disable emitting files from a compilation. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+
+ /* Interop Constraints */
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+
+ /* Type Checking */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+
+ /* Completeness */
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ }
+}