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. */ + } +}