shellquest
Version:
Terminal-based procedurally generated dungeon crawler
403 lines (349 loc) • 10.6 kB
text/typescript
/**
* Dungeon Utilities - Constants, Types, and Noise Functions
* Ported from web/src/app/routes/wall-gen/tileUtils.ts for TUI game
*/
// =============================================================================
// CONSTANTS
// =============================================================================
export const DUNGEON_TILE_SIZE = 16;
export const DUNGEON_MAP_WIDTH = 100;
export const DUNGEON_MAP_HEIGHT = 100;
// Cell types
export const CELL_FLOOR = 0;
export const CELL_WALL = 1;
export type CellType = typeof CELL_FLOOR | typeof CELL_WALL;
export type TileCoords = [col: number, row: number];
export type TileGrid = CellType[][];
// =============================================================================
// SEEDED RANDOM NUMBER GENERATOR
// =============================================================================
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
next(): number {
let t = (this.seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min + 1)) + min;
}
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
nextBool(p: number = 0.5): boolean {
return this.next() < p;
}
shuffle<T>(array: T[]): T[] {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(this.next() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
pick<T>(array: T[]): T {
return array[Math.floor(this.next() * array.length)];
}
}
// =============================================================================
// SIMPLE NOISE IMPLEMENTATION (no external dependencies)
// =============================================================================
// Simple seeded noise implementation
let noiseSeed = 12345;
let noisePermutation: number[] = [];
function initNoisePermutation(seed: number): void {
const rng = new SeededRandom(seed);
noisePermutation = [];
for (let i = 0; i < 256; i++) {
noisePermutation[i] = i;
}
// Shuffle
for (let i = 255; i > 0; i--) {
const j = Math.floor(rng.next() * (i + 1));
[noisePermutation[i], noisePermutation[j]] = [noisePermutation[j], noisePermutation[i]];
}
// Duplicate for overflow
for (let i = 0; i < 256; i++) {
noisePermutation[256 + i] = noisePermutation[i];
}
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerp(a: number, b: number, t: number): number {
return a + t * (b - a);
}
function grad(hash: number, x: number, y: number): number {
const h = hash & 3;
const u = h < 2 ? x : y;
const v = h < 2 ? y : x;
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}
function perlin2D(x: number, y: number): number {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const xf = x - Math.floor(x);
const yf = y - Math.floor(y);
const u = fade(xf);
const v = fade(yf);
const aa = noisePermutation[noisePermutation[X] + Y];
const ab = noisePermutation[noisePermutation[X] + Y + 1];
const ba = noisePermutation[noisePermutation[X + 1] + Y];
const bb = noisePermutation[noisePermutation[X + 1] + Y + 1];
const x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u);
const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u);
return lerp(x1, x2, v);
}
export function setNoiseSeed(seed: number): void {
noiseSeed = seed;
initNoisePermutation(seed);
}
export function getNoiseSeed(): number {
return noiseSeed;
}
// Initialize with default seed
initNoisePermutation(noiseSeed);
// Get 2D noise value in range [0, 1]
export function getNoise2D(x: number, y: number, scale: number = 0.1): number {
return (perlin2D(x * scale, y * scale) + 1) / 2;
}
// Get layered noise (octaves) for more natural variation
export function getLayeredNoise(
x: number,
y: number,
octaves: number = 3,
persistence: number = 0.5,
scale: number = 0.1
): number {
let total = 0;
let frequency = scale;
let amplitude = 1;
let maxValue = 0;
for (let i = 0; i < octaves; i++) {
total += ((perlin2D(x * frequency, y * frequency) + 1) / 2) * amplitude;
maxValue += amplitude;
amplitude *= persistence;
frequency *= 2;
}
return total / maxValue;
}
// Generate deterministic noise value for a tile (for texture variation)
export function getTileNoise(x: number, y: number): number {
return Math.floor(getNoise2D(x, y, 0.15) * 100);
}
// =============================================================================
// NEIGHBOR UTILITIES
// =============================================================================
export interface Neighbors {
n: boolean; // north
s: boolean; // south
e: boolean; // east
w: boolean; // west
ne: boolean; // northeast
nw: boolean; // northwest
se: boolean; // southeast
sw: boolean; // southwest
}
export function getCell(grid: TileGrid, x: number, y: number): CellType {
if (x < 0 || x >= grid[0].length || y < 0 || y >= grid.length) {
return CELL_WALL;
}
return grid[y][x];
}
export function isWall(grid: TileGrid, x: number, y: number): boolean {
return getCell(grid, x, y) === CELL_WALL;
}
export function isFloor(grid: TileGrid, x: number, y: number): boolean {
return getCell(grid, x, y) === CELL_FLOOR;
}
export function getNeighbors(grid: TileGrid, x: number, y: number): Neighbors {
return {
n: isWall(grid, x, y - 1),
s: isWall(grid, x, y + 1),
e: isWall(grid, x + 1, y),
w: isWall(grid, x - 1, y),
ne: isWall(grid, x + 1, y - 1),
nw: isWall(grid, x - 1, y - 1),
se: isWall(grid, x + 1, y + 1),
sw: isWall(grid, x - 1, y + 1),
};
}
export function isWallVisible(grid: TileGrid, x: number, y: number): boolean {
return (
isFloor(grid, x, y - 1) ||
isFloor(grid, x, y + 1) ||
isFloor(grid, x + 1, y) ||
isFloor(grid, x - 1, y) ||
isWall(grid, x, y + 1)
);
}
// =============================================================================
// DECAL SYSTEM TYPES
// =============================================================================
export interface DecalPlacement {
definitionId: string;
x: number;
y: number;
}
export type TrackDirection = 'up' | 'down' | 'left' | 'right';
export interface TrackSegment {
x: number;
y: number;
tileId: string; // Use tile name instead of coords for TUI
}
export interface TrackPath {
segments: TrackSegment[];
}
export interface FenceSegment {
x: number;
y: number;
tileId: string; // Use tile name instead of coords for TUI
}
export interface FencePlacement {
segments: FenceSegment[];
}
// =============================================================================
// DECAL DEFINITIONS
// =============================================================================
export interface DecalDefinition {
id: string;
name: string;
tiles: string[]; // Tile names from bottom to top
width: 1 | 2;
frequency: number;
minSpacing: number;
placement: 'wall_face' | 'ground' | 'wall_to_ground';
nearWall?: boolean;
group?: string;
}
export const DECAL_DEFINITIONS: DecalDefinition[] = [
// Ground decals
{
id: 'table',
name: 'Table',
tiles: ['dungeon-decal-table'],
width: 1,
frequency: 0.003,
minSpacing: 2,
placement: 'ground',
},
{
id: 'ground-knub',
name: 'Ground Knub',
tiles: ['dungeon-decal-ground-knub'],
width: 1,
frequency: 0.025,
minSpacing: 3,
placement: 'ground',
},
{
id: 'ground-anvil',
name: 'Ground Anvil',
tiles: ['dungeon-decal-ground-anvil'],
width: 1,
frequency: 0.004,
minSpacing: 2,
placement: 'ground',
},
{
id: 'ground-column',
name: 'Ground Column',
tiles: ['dungeon-decal-ground-column-bottom', 'dungeon-decal-ground-column-top'],
width: 1,
frequency: 0.01,
minSpacing: 3,
placement: 'ground',
nearWall: false,
},
{
id: 'chest-open-tongue',
name: 'Chest Open Tongue',
tiles: ['dungeon-decal-chest-open'],
width: 1,
frequency: 0.004,
minSpacing: 4,
placement: 'ground',
},
// Wall decals
{
id: 'wall_crack1',
name: 'Wall Crack 1',
tiles: ['dungeon-decal-wall-crack1'],
width: 1,
frequency: 0.04,
minSpacing: 4,
placement: 'wall_face',
},
{
id: 'wall_crack2',
name: 'Wall Crack 2',
tiles: ['dungeon-decal-wall-crack2'],
width: 1,
frequency: 0.04,
minSpacing: 4,
placement: 'wall_face',
},
{
id: 'wall-face-1',
name: 'Wall Face 1',
tiles: ['dungeon-decal-wall-face1-bottom', 'dungeon-decal-wall-face1-top'],
width: 1,
frequency: 0.03,
minSpacing: 5,
placement: 'wall_to_ground',
},
{
id: 'wall-face-2',
name: 'Wall Face 2',
tiles: ['dungeon-decal-wall-face2-bottom', 'dungeon-decal-wall-face2-top'],
width: 1,
frequency: 0.03,
minSpacing: 5,
placement: 'wall_to_ground',
},
{
id: 'wall_detail1',
name: 'Wall Detail 1',
tiles: ['dungeon-decal-wall-detail1'],
width: 1,
frequency: 0.02,
minSpacing: 6,
placement: 'wall_face',
},
{
id: 'wall_detail2',
name: 'Wall Detail 2',
tiles: ['dungeon-decal-wall-detail2'],
width: 1,
frequency: 0.02,
minSpacing: 6,
placement: 'wall_face',
},
{
id: 'wall_detail3',
name: 'Wall Detail 3',
tiles: ['dungeon-decal-wall-detail3'],
width: 1,
frequency: 0.02,
minSpacing: 6,
placement: 'wall_face',
},
// Pillar (wall_to_ground)
{
id: 'pillar',
name: 'Stone Pillar',
tiles: ['dungeon-decal-pillar-base', 'dungeon-decal-pillar-middle', 'dungeon-decal-pillar-crown'],
width: 1,
frequency: 0.008,
minSpacing: 6,
placement: 'wall_to_ground',
group: 'pillar',
},
];
export function getDecalDefinition(id: string): DecalDefinition | undefined {
return DECAL_DEFINITIONS.find((d) => d.id === id);
}