feat: 1995
This commit is contained in:
commit
fb89263930
69 changed files with 3359 additions and 0 deletions
45
app/assets.ts
Normal file
45
app/assets.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { Texture } from "../engine/render/Texture"
|
||||
import crateUrl from "../assets/crate.png"
|
||||
import floorUrl from "../assets/floor.png"
|
||||
import npcUrl from "../assets/npc.png"
|
||||
import wallUrl from "../assets/wall.png"
|
||||
|
||||
export type Textures = {
|
||||
floor: Texture
|
||||
wall: Texture
|
||||
crate: Texture
|
||||
npc: Texture
|
||||
}
|
||||
|
||||
/** Load every game texture up front. Call once before starting the loop. */
|
||||
export async function loadTextures(): Promise<Textures> {
|
||||
const [floor, wall, crate, npc] = await Promise.all([
|
||||
loadTexture(floorUrl),
|
||||
loadTexture(wallUrl),
|
||||
loadTexture(crateUrl),
|
||||
loadTexture(npcUrl),
|
||||
])
|
||||
return { floor, wall, crate, npc }
|
||||
}
|
||||
|
||||
function loadTexture(url: string): Promise<Texture> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener("load", () => resolve(toTexture(image)))
|
||||
image.addEventListener("error", () => reject(new Error(`failed to load ${url}`)))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/** Draw a loaded image into a canvas and read its pixels back as a Texture.
|
||||
* ImageData bytes are RGBA, identical to how Color packs a Uint32, so the
|
||||
* buffer is reused directly with no per-pixel conversion. */
|
||||
function toTexture(image: HTMLImageElement): Texture {
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const ctx = canvas.getContext("2d")!
|
||||
ctx.drawImage(image, 0, 0)
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
return { width: pixels.width, height: pixels.height, data: new Uint32Array(pixels.data.buffer) }
|
||||
}
|
||||
140
app/level.ts
Normal file
140
app/level.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { Color } from "../engine/render/Color"
|
||||
import type { SkyConfig } from "../engine/render/Sky"
|
||||
import type { Mesh, Vertex } from "../engine/scene/Mesh"
|
||||
|
||||
type Corner = [number, number, number]
|
||||
|
||||
/** Axis-aligned solid. Blocks the player horizontally while their feet are
|
||||
* below `top`; if `standable`, its `top` also counts as ground to land on. */
|
||||
export type Aabb = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
top: number
|
||||
standable: boolean
|
||||
}
|
||||
|
||||
/** The playground: geometry split by texture, its collision solids, where the
|
||||
* NPC stands, and the sky to draw behind it. */
|
||||
export type Level = {
|
||||
floor: Mesh
|
||||
walls: Mesh
|
||||
crate: Mesh
|
||||
colliders: Aabb[]
|
||||
npcPosition: { x: number; y: number; z: number }
|
||||
sky: SkyConfig
|
||||
}
|
||||
|
||||
const ARENA = 12
|
||||
const WALL_HEIGHT = 5
|
||||
const CRATE = { x: -2, z: -2, half: 1, top: 1 }
|
||||
|
||||
export function buildLevel(): Level {
|
||||
const floor = mesh()
|
||||
quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12, 16)
|
||||
|
||||
const walls = mesh()
|
||||
const h = WALL_HEIGHT
|
||||
// Inward-facing perimeter, no ceiling so the sky shows above.
|
||||
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5, 12)
|
||||
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5, 12)
|
||||
|
||||
const crate = mesh()
|
||||
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
|
||||
|
||||
const colliders: Aabb[] = [
|
||||
wall(-ARENA, ARENA, -ARENA, -ARENA + 1),
|
||||
wall(-ARENA, ARENA, ARENA - 1, ARENA),
|
||||
wall(ARENA - 1, ARENA, -ARENA, ARENA),
|
||||
wall(-ARENA, -ARENA + 1, -ARENA, ARENA),
|
||||
{
|
||||
minX: CRATE.x - CRATE.half,
|
||||
maxX: CRATE.x + CRATE.half,
|
||||
minZ: CRATE.z - CRATE.half,
|
||||
maxZ: CRATE.z + CRATE.half,
|
||||
top: CRATE.top,
|
||||
standable: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sky: SkyConfig = {
|
||||
zenith: Color.rgb(58, 108, 196),
|
||||
horizon: Color.rgb(178, 198, 226),
|
||||
sun: Color.rgb(255, 246, 214),
|
||||
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
||||
sunSize: 0.04,
|
||||
}
|
||||
|
||||
return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky }
|
||||
}
|
||||
|
||||
function mesh(): Mesh {
|
||||
return { vertices: [], indices: [] }
|
||||
}
|
||||
|
||||
function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
|
||||
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false }
|
||||
}
|
||||
|
||||
/** A quad tessellated into an n*n grid so affine texture warp stays per-tile.
|
||||
* Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
|
||||
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number, n: number): void {
|
||||
const base = m.vertices.length
|
||||
const row = n + 1
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = i / n
|
||||
for (let j = 0; j <= n; j++) {
|
||||
const s = j / n
|
||||
const wa = (1 - s) * (1 - t)
|
||||
const wb = s * (1 - t)
|
||||
const wc = s * t
|
||||
const wd = (1 - s) * t
|
||||
m.vertices.push({
|
||||
pos: {
|
||||
x: a[0] * wa + b[0] * wb + c[0] * wc + d[0] * wd,
|
||||
y: a[1] * wa + b[1] * wb + c[1] * wc + d[1] * wd,
|
||||
z: a[2] * wa + b[2] * wb + c[2] * wc + d[2] * wd,
|
||||
},
|
||||
uv: { x: us * s, y: vs * t },
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const p = base + i * row + j
|
||||
m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A box centered at (cx, cz) on the floor: top face plus four sides, one uv
|
||||
* tile per face. No bottom (never seen). */
|
||||
function box(m: Mesh, cx: number, cz: number, half: number, top: number): void {
|
||||
const x0 = cx - half
|
||||
const x1 = cx + half
|
||||
const z0 = cz - half
|
||||
const z1 = cz + half
|
||||
quad(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1])
|
||||
quad(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0])
|
||||
quad(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1])
|
||||
quad(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0])
|
||||
quad(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1])
|
||||
}
|
||||
|
||||
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner): void {
|
||||
const base = m.vertices.length
|
||||
const corners: [Corner, [number, number]][] = [
|
||||
[a, [0, 0]],
|
||||
[b, [1, 0]],
|
||||
[c, [1, 1]],
|
||||
[d, [0, 1]],
|
||||
]
|
||||
for (const [pos, uv] of corners) {
|
||||
const vertex: Vertex = { pos: { x: pos[0], y: pos[1], z: pos[2] }, uv: { x: uv[0], y: uv[1] } }
|
||||
m.vertices.push(vertex)
|
||||
}
|
||||
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
112
app/main.ts
Normal file
112
app/main.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { Framebuffer } from "../engine/render/Framebuffer"
|
||||
import { Rasterizer } from "../engine/render/Rasterizer"
|
||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||
import { Sky } from "../engine/render/Sky"
|
||||
import { Camera } from "../engine/scene/Camera"
|
||||
import { Sprite } from "../engine/scene/Sprite"
|
||||
import { loadTextures } from "./assets"
|
||||
import { buildLevel } from "./level"
|
||||
import { EYE_HEIGHT, Player } from "./player"
|
||||
|
||||
const FOV = Math.PI / 3
|
||||
|
||||
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||
const ctx = screen.getContext("2d")!
|
||||
const back = document.createElement("canvas")
|
||||
const backCtx = back.getContext("2d")!
|
||||
|
||||
let config: RenderConfig = RenderConfig.psxish
|
||||
let fb = Framebuffer.create(1, 1)
|
||||
let image = new ImageData(1, 1)
|
||||
|
||||
function useConfig(next: RenderConfig): void {
|
||||
config = next
|
||||
fb = Framebuffer.create(config.internalWidth, config.internalHeight)
|
||||
back.width = fb.width
|
||||
back.height = fb.height
|
||||
image = new ImageData(new Uint8ClampedArray(fb.color.buffer as ArrayBuffer), fb.width, fb.height)
|
||||
}
|
||||
|
||||
function resize(): void {
|
||||
screen.width = globalThis.innerWidth
|
||||
screen.height = globalThis.innerHeight
|
||||
}
|
||||
|
||||
function present(): void {
|
||||
backCtx.putImageData(image, 0, 0)
|
||||
const scale = Math.max(1, Math.floor(Math.min(screen.width / fb.width, screen.height / fb.height)))
|
||||
const w = fb.width * scale
|
||||
const h = fb.height * scale
|
||||
const x = (screen.width - w) >> 1
|
||||
const y = (screen.height - h) >> 1
|
||||
ctx.imageSmoothingEnabled = config.upscaleFilter === "linear"
|
||||
ctx.clearRect(0, 0, screen.width, screen.height)
|
||||
ctx.drawImage(back, x, y, w, h)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const textures = await loadTextures()
|
||||
const level = buildLevel()
|
||||
const npc: Sprite = { position: level.npcPosition, size: { x: 1.1, y: 1.5 }, texture: textures.npc }
|
||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
||||
|
||||
const keys = new Set<string>()
|
||||
globalThis.addEventListener("keydown", (e) => {
|
||||
keys.add(e.code)
|
||||
if (e.code === "Digit1") {
|
||||
useConfig(RenderConfig.psxish)
|
||||
}
|
||||
if (e.code === "Digit2") {
|
||||
useConfig(RenderConfig.soft)
|
||||
}
|
||||
if (e.code === "Digit3") {
|
||||
useConfig(RenderConfig.clean)
|
||||
}
|
||||
})
|
||||
globalThis.addEventListener("keyup", (e) => {
|
||||
keys.delete(e.code)
|
||||
})
|
||||
screen.addEventListener("click", () => {
|
||||
screen.requestPointerLock()
|
||||
})
|
||||
globalThis.addEventListener("mousemove", (e) => {
|
||||
if (document.pointerLockElement !== screen) {
|
||||
return
|
||||
}
|
||||
player.yaw += e.movementX * 0.0025
|
||||
player.pitch = Math.max(-1.4, Math.min(1.4, player.pitch - e.movementY * 0.0025))
|
||||
})
|
||||
|
||||
useConfig(config)
|
||||
globalThis.addEventListener("resize", resize)
|
||||
resize()
|
||||
|
||||
let last = performance.now()
|
||||
function frame(now: number): void {
|
||||
const dt = Math.min(0.05, (now - last) / 1000)
|
||||
last = now
|
||||
Player.update(player, keys, dt, level)
|
||||
|
||||
const camera: Camera = {
|
||||
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
|
||||
yaw: player.yaw,
|
||||
pitch: player.pitch,
|
||||
fov: FOV,
|
||||
}
|
||||
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
|
||||
|
||||
Sky.render(fb, camera, level.sky)
|
||||
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
|
||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
||||
Rasterizer.draw(fb, Sprite.billboard(npc, camera), textures.npc, viewProj, config)
|
||||
Framebuffer.quantize(fb, config)
|
||||
present()
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
146
app/player.ts
Normal file
146
app/player.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { Vec3 } from "../engine/math/Vec3"
|
||||
import type { Aabb, Level } from "./level"
|
||||
|
||||
/** The player as a vertical cylinder. `position` is at the feet; the camera
|
||||
* eye sits EYE_HEIGHT above it. */
|
||||
export type Player = {
|
||||
position: Vec3
|
||||
yaw: number
|
||||
pitch: number
|
||||
velocityY: number
|
||||
onGround: boolean
|
||||
}
|
||||
|
||||
export const EYE_HEIGHT = 1.6
|
||||
const RADIUS = 0.35
|
||||
const SPEED = 4
|
||||
const GRAVITY = 22
|
||||
const JUMP_SPEED = 8
|
||||
const NPC_RADIUS = 0.5
|
||||
|
||||
export namespace Player {
|
||||
/** Advance the player one frame: jump, horizontal move + collision, gravity. */
|
||||
export function update(player: Player, keys: Set<string>, dt: number, level: Level): void {
|
||||
if (keys.has("Space") && player.onGround) {
|
||||
player.velocityY = JUMP_SPEED
|
||||
player.onGround = false
|
||||
}
|
||||
moveHorizontal(player, keys, dt)
|
||||
collide(player, level)
|
||||
fall(player, dt, level)
|
||||
}
|
||||
|
||||
function moveHorizontal(player: Player, keys: Set<string>, dt: number): void {
|
||||
const speed = SPEED * dt
|
||||
const fx = Math.sin(player.yaw)
|
||||
const fz = -Math.cos(player.yaw)
|
||||
const rx = Math.cos(player.yaw)
|
||||
const rz = Math.sin(player.yaw)
|
||||
const p = player.position
|
||||
if (keys.has("KeyW")) {
|
||||
p.x += fx * speed
|
||||
p.z += fz * speed
|
||||
}
|
||||
if (keys.has("KeyS")) {
|
||||
p.x -= fx * speed
|
||||
p.z -= fz * speed
|
||||
}
|
||||
if (keys.has("KeyD")) {
|
||||
p.x += rx * speed
|
||||
p.z += rz * speed
|
||||
}
|
||||
if (keys.has("KeyA")) {
|
||||
p.x -= rx * speed
|
||||
p.z -= rz * speed
|
||||
}
|
||||
}
|
||||
|
||||
/** Push the player's circle out of any solid it overlaps: level colliders it
|
||||
* is not standing above, and the NPC. This is what makes walls and the NPC
|
||||
* impassable while still letting you stand on the crate. */
|
||||
function collide(player: Player, level: Level): void {
|
||||
for (const aabb of level.colliders) {
|
||||
if (player.position.y < aabb.top - 0.01) {
|
||||
pushFromAabb(player.position, aabb)
|
||||
}
|
||||
}
|
||||
pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS)
|
||||
}
|
||||
|
||||
/** Apply gravity and land on the highest ground under the player. */
|
||||
function fall(player: Player, dt: number, level: Level): void {
|
||||
player.velocityY -= GRAVITY * dt
|
||||
player.position.y += player.velocityY * dt
|
||||
const ground = groundHeight(player.position, level)
|
||||
if (player.position.y <= ground) {
|
||||
player.position.y = ground
|
||||
player.velocityY = 0
|
||||
player.onGround = true
|
||||
} else {
|
||||
player.onGround = false
|
||||
}
|
||||
}
|
||||
|
||||
function groundHeight(position: Vec3, level: Level): number {
|
||||
let ground = 0
|
||||
for (const aabb of level.colliders) {
|
||||
if (
|
||||
aabb.standable &&
|
||||
position.x >= aabb.minX &&
|
||||
position.x <= aabb.maxX &&
|
||||
position.z >= aabb.minZ &&
|
||||
position.z <= aabb.maxZ
|
||||
) {
|
||||
ground = Math.max(ground, aabb.top)
|
||||
}
|
||||
}
|
||||
return ground
|
||||
}
|
||||
|
||||
function pushFromAabb(position: Vec3, aabb: Aabb): void {
|
||||
const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x))
|
||||
const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z))
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= RADIUS * RADIUS) {
|
||||
return
|
||||
}
|
||||
if (d2 > 1e-6) {
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (RADIUS - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
return
|
||||
}
|
||||
// Center is inside the box: eject through the nearest face.
|
||||
const left = position.x - aabb.minX
|
||||
const rightSide = aabb.maxX - position.x
|
||||
const near = position.z - aabb.minZ
|
||||
const far = aabb.maxZ - position.z
|
||||
const m = Math.min(left, rightSide, near, far)
|
||||
if (m === left) {
|
||||
position.x = aabb.minX - RADIUS
|
||||
} else if (m === rightSide) {
|
||||
position.x = aabb.maxX + RADIUS
|
||||
} else if (m === near) {
|
||||
position.z = aabb.minZ - RADIUS
|
||||
} else {
|
||||
position.z = aabb.maxZ + RADIUS
|
||||
}
|
||||
}
|
||||
|
||||
function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void {
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const reach = RADIUS + otherRadius
|
||||
const d2 = dx * dx + dz * dz
|
||||
if (d2 >= reach * reach || d2 < 1e-6) {
|
||||
return
|
||||
}
|
||||
const d = Math.sqrt(d2)
|
||||
const push = (reach - d) / d
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue