<!DOCTYPE html>
<html lang=”en”>
<head>
<meta charset=”UTF-8″>
<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
<title>Advanced Wilderness Cartographer</title>
<script src=”https://cdn.tailwindcss.com”></script>
<style>
@import url(‘https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=MedievalSharp&family=Quicksand:wght@300;500&display=swap’);
:root {
–bg-dark: #121212;
–panel-bg: #1e1e1e;
–accent: #d4af37; /* Gold */
–accent-hover: #f1c40f;
}
body {
background-color: var(–bg-dark);
color: #e2e2e2;
font-family: ‘Quicksand’, sans-serif;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
overflow-x: hidden;
}
h1, h2, h3, .map-font {
font-family: ‘Cinzel’, serif;
}
.medieval-font {
font-family: ‘MedievalSharp’, cursive;
}
#app-container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 1400px;
padding: 1rem;
gap: 1.5rem;
}
@media (min-width: 1024px) {
#app-container {
flex-direction: row;
align-items: flex-start;
}
}
#canvas-wrapper {
position: relative;
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
background: #0a0a0a;
padding: 1rem;
border-radius: 8px;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.8);
overflow: hidden;
}
canvas {
max-width: 100%;
height: auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.6);
transition: transform 0.3s ease;
}
.controls-panel {
background: var(–panel-bg);
border: 1px solid #333;
border-radius: 12px;
padding: 1.5rem;
width: 100%;
min-width: 320px;
max-width: 400px;
flex-shrink: 0;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.control-group {
margin-bottom: 1.2rem;
padding-bottom: 1.2rem;
border-bottom: 1px solid #2a2a2a;
}
.control-group:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
input[type=range] {
width: 100%;
accent-color: var(–accent);
height: 6px;
background: #333;
border-radius: 3px;
outline: none;
-webkit-appearance: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(–accent);
cursor: pointer;
}
select, input[type=text] {
width: 100%;
background: #2a2a2a;
border: 1px solid #444;
color: white;
padding: 0.5rem;
border-radius: 6px;
font-family: ‘Quicksand’, sans-serif;
}
select:focus, input[type=text]:focus {
outline: none;
border-color: var(–accent);
}
.btn {
width: 100%;
padding: 0.75rem;
border-radius: 6px;
font-family: ‘Cinzel’, serif;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(–accent);
color: #121212;
}
.btn-primary:hover {
background: var(–accent-hover);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(212, 175, 55, 0.3);
}
.btn-secondary {
background: #333;
color: #e2e2e2;
border: 1px solid #555;
}
.btn-secondary:hover {
background: #444;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
}
.checkbox-container input {
accent-color: var(–accent);
width: 16px;
height: 16px;
}
/* Custom scrollbar for panel */
.controls-panel::-webkit-scrollbar { width: 8px; }
.controls-panel::-webkit-scrollbar-track { background: #1e1e1e; }
.controls-panel::-webkit-scrollbar-thumb { background: #444; border-radius: 4px; }
</style>
</head>
<body>
<div class=”text-center mt-6 mb-2 w-full”>
<h1 class=”text-3xl md:text-5xl font-bold text-[#d4af37] tracking-widest drop-shadow-lg”>Cartographer’s Sanctum</h1>
<p class=”text-gray-400 italic mt-2 text-sm md:text-base”>Advanced Procedural Atlas Generation</p>
</div>
<div id=”app-container”>
<!– Sidebar Controls –>
<div class=”controls-panel overflow-y-auto max-h-[85vh]”>
<div class=”control-group”>
<button id=”generateBtn” class=”btn btn-primary mb-3″>Forge New World</button>
<div class=”flex gap-2″>
<input type=”text” id=”seedInput” placeholder=”Enter random seed…” class=”text-sm”>
<button id=”randomSeedBtn” class=”btn btn-secondary w-auto px-3″ title=”Random Seed”>🎲</button>
</div>
</div>
<div class=”control-group”>
<h3 class=”text-[#d4af37] text-sm tracking-widest mb-3″>Aesthetics</h3>
<label class=”block text-xs text-gray-400 mb-1″>Map Theme</label>
<select id=”themeSelect” class=”mb-3″>
<option value=”parchment”>Ancient Parchment (Classic)</option>
<option value=”atlas”>Imperial Atlas (Vibrant)</option>
<option value=”monochrome”>Scholar’s Ink (B&W)</option>
<option value=”dark”>Midnight Vellum (Dark Mode)</option>
</select>
<label class=”block text-xs text-gray-400 mb-1 mt-2″>Map Resolution</label>
<select id=”resSelect” class=”mb-3″>
<option value=”1″>Standard (Faster)</option>
<option value=”2″>High (Detailed, Slower)</option>
</select>
</div>
<div class=”control-group”>
<h3 class=”text-[#d4af37] text-sm tracking-widest mb-3″>Geography (Whittaker Model)</h3>
<div class=”mb-4″>
<div class=”flex justify-between text-xs text-gray-400 mb-1″>
<span>Landmass Scale</span>
<span id=”scaleVal”>150</span>
</div>
<input type=”range” id=”scale” min=”50″ max=”400″ value=”180″>
</div>
<div class=”mb-4″>
<div class=”flex justify-between text-xs text-gray-400 mb-1″>
<span>Sea Level</span>
<span id=”waterVal”>45%</span>
</div>
<input type=”range” id=”waterLevel” min=”20″ max=”80″ value=”45″>
</div>
<div class=”mb-2″>
<div class=”flex justify-between text-xs text-gray-400 mb-1″>
<span>Global Moisture</span>
<span id=”moistVal”>50%</span>
</div>
<input type=”range” id=”moisture” min=”-50″ max=”50″ value=”0″>
</div>
</div>
<div class=”control-group”>
<h3 class=”text-[#d4af37] text-sm tracking-widest mb-3″>Cartography Options</h3>
<label class=”checkbox-container text-sm text-gray-300″>
<input type=”checkbox” id=”drawCoastlines” checked> Ink Coastlines
</label>
<label class=”checkbox-container text-sm text-gray-300″>
<input type=”checkbox” id=”drawSymbols” checked> Draw Terrain Symbols (Trees/Mts)
</label>
<label class=”checkbox-container text-sm text-gray-300″>
<input type=”checkbox” id=”drawPOIs” checked> Generate Cities & Ruins
</label>
<label class=”checkbox-container text-sm text-gray-300″>
<input type=”checkbox” id=”drawGrid”> Hexagonal Overlay
</label>
<label class=”checkbox-container text-sm text-gray-300″>
<input type=”checkbox” id=”drawDecor” checked> Compass & Title Ribbon
</label>
</div>
<div class=”control-group”>
<button id=”downloadBtn” class=”btn btn-secondary flex items-center justify-center gap-2″>
<svg class=”w-4 h-4″ fill=”none” stroke=”currentColor” viewBox=”0 0 24 24″><path stroke-linecap=”round” stroke-linejoin=”round” stroke-width=”2″ d=”M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4″></path></svg>
Export as PNG
</button>
</div>
</div>
<!– Canvas Area –>
<div id=”canvas-wrapper”>
<canvas id=”mapCanvas”></canvas>
</div>
</div>
<script>
/**
* Robust seeded random number generator (Mulberry32)
*/
function mulberry32(a) {
return function() {
var t = a += 0x6D2B79F5;
t = Math.imul(t ^ t >>> 15, t | 1);
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
return ((t ^ t >>> 14) >>> 0) / 4294967296;
}
}
/**
* Procedural Noise Implementation (Simplex-like Perlin)
*/
class Noise {
constructor(seedFunc) {
this.p = new Uint8Array(512);
const permutation = new Uint8Array(256);
for (let i = 0; i < 256; i++) permutation[i] = i;
for (let i = 255; i > 0; i–) {
const j = Math.floor(seedFunc() * (i + 1));
[permutation[i], permutation[j]] = [permutation[j], permutation[i]];
}
for (let i = 0; i < 512; i++) this.p[i] = permutation[i % 256];
}
fade(t) { return t * t * t * (t * (t * 6 – 15) + 10); }
lerp(t, a, b) { return a + t * (b – a); }
grad(hash, x, y) {
const h = hash & 7;
const u = h < 4 ? x : y;
const v = h < 4 ? y : x;
return ((h & 1) ? -u : u) + ((h & 2) ? -2.0*v : 2.0*v);
}
noise(x, y) {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
const u = this.fade(x);
const v = this.fade(y);
const A = this.p[X] + Y, AA = this.p[A], AB = this.p[A + 1];
const B = this.p[X + 1] + Y, BA = this.p[B], BB = this.p[B + 1];
return this.lerp(v, this.lerp(u, this.grad(this.p[AA], x, y), this.grad(this.p[BA], x – 1, y)),
this.lerp(u, this.grad(this.p[AB], x, y – 1), this.grad(this.p[BB], x – 1, y – 1)));
}
fbm(x, y, octaves = 4, persistence = 0.5, lacunarity = 2) {
let total = 0;
let frequency = 1;
let amplitude = 1;
let maxValue = 0;
for(let i=0; i<octaves; i++) {
total += this.noise(x * frequency, y * frequency) * amplitude;
maxValue += amplitude;
amplitude *= persistence;
frequency *= lacunarity;
}
return total / maxValue;
}
}
// — Application State & Configuration —
const canvas = document.getElementById(‘mapCanvas’);
const ctx = canvas.getContext(‘2d’, { alpha: false }); // Optimize
let width = 1000;
let height = 700;
let rng; // Random Number Generator function
let elevNoise, moistNoise;
// Theme Definitions
const THEMES = {
parchment: {
bg: ‘#f4e4bc’,
ink: ‘#3d2b1f’,
waterline: ‘rgba(61, 43, 31, 0.4)’,
biomes: {
deepWater: ‘#d1bfae’, water: ‘#e0cdb8’, beach: ‘#eeddbd’,
desert: ‘#e8d2a6’, savanna: ‘#dfc999’, tropical: ‘#c5b68d’,
grassland: ‘#d4c79e’, forest: ‘#b3a886’, taiga: ‘#c4baa1’,
tundra: ‘#e2ddcd’, snow: ‘#f5f2eb’, mountain: ‘#b0a696’
}
},
atlas: {
bg: ‘#ffffff’,
ink: ‘#1a1a1a’,
waterline: ‘#2980b9’,
biomes: {
deepWater: ‘#1c5c87’, water: ‘#3498db’, beach: ‘#f1c40f’,
desert: ‘#e67e22’, savanna: ‘#f39c12’, tropical: ‘#27ae60’,
grassland: ‘#2ecc71’, forest: ‘#229954’, taiga: ‘#145a32’,
tundra: ‘#95a5a6’, snow: ‘#ecf0f1’, mountain: ‘#7f8c8d’
}
},
monochrome: {
bg: ‘#ffffff’,
ink: ‘#000000’,
waterline: ‘#000000’,
biomes: {
deepWater: ‘#e0e0e0’, water: ‘#ffffff’, beach: ‘#ffffff’,
desert: ‘#ffffff’, savanna: ‘#ffffff’, tropical: ‘#ffffff’,
grassland: ‘#ffffff’, forest: ‘#ffffff’, taiga: ‘#ffffff’,
tundra: ‘#ffffff’, snow: ‘#ffffff’, mountain: ‘#ffffff’ // Rely solely on symbols
}
},
dark: {
bg: ‘#121212’,
ink: ‘#c8a951’, // Gold ink
waterline: ‘#1f3a4d’,
biomes: {
deepWater: ‘#0a1118’, water: ‘#121f2b’, beach: ‘#242018’,
desert: ‘#2e2213’, savanna: ‘#272614’, tropical: ‘#122617’,
grassland: ‘#1a2b1c’, forest: ‘#102116’, taiga: ‘#152424’,
tundra: ‘#2c3033’, snow: ‘#4a4e52’, mountain: ‘#2a2a2a’
}
}
};
let currentTheme = THEMES.parchment;
// — Core Functions —
function init() {
// Setup Listeners
document.getElementById(‘generateBtn’).addEventListener(‘click’, generateMap);
document.getElementById(‘downloadBtn’).addEventListener(‘click’, downloadMap);
document.getElementById(‘randomSeedBtn’).addEventListener(‘click’, () => {
document.getElementById(‘seedInput’).value = Math.random().toString(36).substring(2, 8).toUpperCase();
});
// Sync slider values
const sliders = [‘scale’, ‘waterLevel’, ‘moisture’];
sliders.forEach(id => {
const el = document.getElementById(id);
el.addEventListener(‘input’, (e) => {
let val = e.target.value;
if(id === ‘waterLevel’) val += ‘%’;
if(id === ‘moisture’) val = (val > 0 ? ‘+’ : ”) + val + ‘%’;
document.getElementById(id.replace(‘Level’, ”).replace(‘ure’, ”) + ‘Val’).innerText = val;
});
});
// Initial random seed
document.getElementById(‘randomSeedBtn’).click();
// First render
generateMap();
}
function getBiome(e, m) {
// e = elevation (0 to 1), m = moisture (0 to 1)
const waterLevel = parseInt(document.getElementById(‘waterLevel’).value) / 100;
if (e < waterLevel – 0.1) return ‘deepWater’;
if (e < waterLevel) return ‘water’;
if (e < waterLevel + 0.03) return ‘beach’;
// High Elevation
if (e > 0.8) return ‘snow’;
if (e > 0.65) {
if (m < 0.33) return ‘mountain’;
if (m < 0.66) return ‘tundra’;
return ‘snow’;
}
// Mid/Low Elevation
if (m < 0.2) return ‘desert’;
if (m < 0.4) return ‘savanna’;
if (m < 0.65) return ‘grassland’;
if (m < 0.85) return ‘forest’;
if (e > waterLevel + 0.2) return ‘taiga’; // Cold wet
return ‘tropical’; // Hot wet
}
// — Drawing Helpers —
function setInk(alpha = 1, forceTheme = null) {
const theme = forceTheme || currentTheme;
if(alpha === 1) {
ctx.strokeStyle = theme.ink;
ctx.fillStyle = theme.ink;
} else {
// Convert hex to rgba manually for simplicity assuming hex is #RRGGBB
let hex = theme.ink.replace(‘#’,”);
if(hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
let r = parseInt(hex.substring(0,2), 16);
let g = parseInt(hex.substring(2,4), 16);
let b = parseInt(hex.substring(4,6), 16);
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`;
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
}
}
function drawMountain(x, y, size) {
setInk(0.9);
ctx.lineWidth = 1.5;
ctx.beginPath();
// Main peak
ctx.moveTo(x – size, y + size*0.8);
ctx.lineTo(x, y – size);
ctx.lineTo(x + size, y + size*0.8);
// Detail lines
ctx.moveTo(x, y – size);
ctx.lineTo(x – size*0.3, y + size*0.4);
ctx.moveTo(x, y – size);
ctx.lineTo(x + size*0.2, y + size*0.2);
ctx.stroke();
// Fill background so lines don’t show through
ctx.fillStyle = currentTheme.biomes.mountain;
ctx.fill();
ctx.stroke();
}
function drawTree(x, y, size, type) {
setInk(0.8);
ctx.lineWidth = 1;
if (type === ‘pine’ || type === ‘taiga’) {
ctx.beginPath();
ctx.moveTo(x, y + size);
ctx.lineTo(x, y – size);
ctx.moveTo(x, y – size);
ctx.lineTo(x – size*0.6, y + size*0.3);
ctx.moveTo(x, y – size);
ctx.lineTo(x + size*0.6, y + size*0.3);
ctx.stroke();
} else if (type === ‘broadleaf’ || type === ‘forest’) {
ctx.beginPath();
ctx.arc(x, y – size*0.3, size*0.6, 0, Math.PI * 2);
ctx.fillStyle = currentTheme.biomes.forest;
if(document.getElementById(‘themeSelect’).value === ‘monochrome’) ctx.fillStyle = ‘#fff’;
ctx.fill();
ctx.stroke();
// Trunk
ctx.beginPath();
ctx.moveTo(x, y + size*0.3);
ctx.lineTo(x, y + size);
ctx.stroke();
} else if (type === ‘jungle’ || type === ‘tropical’) {
ctx.beginPath();
ctx.moveTo(x, y+size);
ctx.lineTo(x, y-size*0.5); // trunk
// Fronds
ctx.moveTo(x, y-size*0.5); ctx.quadraticCurveTo(x+size, y-size, x+size, y);
ctx.moveTo(x, y-size*0.5); ctx.quadraticCurveTo(x-size, y-size, x-size, y);
ctx.stroke();
} else if (type === ‘cactus’ || type === ‘desert’) {
ctx.beginPath();
ctx.moveTo(x, y+size); ctx.lineTo(x, y-size*0.5); // main
ctx.moveTo(x, y); ctx.lineTo(x+size*0.4, y); ctx.lineTo(x+size*0.4, y-size*0.8); // right arm
ctx.moveTo(x, y+size*0.3); ctx.lineTo(x-size*0.4, y+size*0.3); ctx.lineTo(x-size*0.4, y-size*0.2); // left arm
ctx.stroke();
}
}
function drawCity(x, y, isCapital, name) {
setInk(1);
ctx.lineWidth = 1.5;
if (isCapital) {
// Draw Star/Castle
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.fillStyle = currentTheme.ink;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, 9, 0, Math.PI * 2);
ctx.stroke();
} else {
// Small dot/house
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = currentTheme.ink;
ctx.fill();
}
// Name
ctx.font = “bold 14px ‘MedievalSharp’, cursive”;
ctx.fillStyle = currentTheme.ink;
ctx.textAlign = “center”;
// Outline text for readability
ctx.strokeStyle = currentTheme.bg;
ctx.lineWidth = 3;
ctx.strokeText(name, x, y – 12);
ctx.fillText(name, x, y – 12);
}
function drawCompass(x, y, size) {
setInk(1);
ctx.lineWidth = 2;
// Outer Ring
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, size * 0.9, 0, Math.PI * 2);
ctx.stroke();
// Points
const drawPoint = (angle, len, width, fillDark) => {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, -len);
ctx.lineTo(width, 0);
ctx.lineTo(0, 0);
ctx.fillStyle = fillDark ? currentTheme.ink : currentTheme.bg;
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, -len);
ctx.lineTo(-width, 0);
ctx.lineTo(0, 0);
ctx.fillStyle = fillDark ? currentTheme.bg : currentTheme.ink;
ctx.fill();
ctx.stroke();
ctx.restore();
};
// Cardinal
for(let i=0; i<4; i++) drawPoint(i * Math.PI/2, size * 1.2, size * 0.25, true);
// Ordinal
for(let i=0; i<4; i++) drawPoint(i * Math.PI/2 + Math.PI/4, size * 0.8, size * 0.15, false);
// N label
ctx.font = “bold 24px ‘Cinzel’, serif”;
ctx.fillStyle = currentTheme.ink;
ctx.textAlign = “center”;
ctx.textBaseline = “middle”;
ctx.fillText(“N”, x, y – size * 1.5);
}
function drawTitleRibbon(text, x, y) {
const width = ctx.measureText(text).width + 80;
const height = 50;
setInk(1);
// Background Ribbon
ctx.fillStyle = currentTheme.bg;
ctx.strokeStyle = currentTheme.ink;
ctx.lineWidth = 2;
// Main body
ctx.fillRect(x – width/2, y – height/2, width, height);
ctx.strokeRect(x – width/2, y – height/2, width, height);
// Tails
const tailW = 30;
ctx.beginPath();
ctx.moveTo(x – width/2, y – height/2 + 10);
ctx.lineTo(x – width/2 – tailW, y – height/2 + 10);
ctx.lineTo(x – width/2 – tailW/2, y);
ctx.lineTo(x – width/2 – tailW, y + height/2 – 10);
ctx.lineTo(x – width/2, y + height/2 – 10);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + width/2, y – height/2 + 10);
ctx.lineTo(x + width/2 + tailW, y – height/2 + 10);
ctx.lineTo(x + width/2 + tailW/2, y);
ctx.lineTo(x + width/2 + tailW, y + height/2 – 10);
ctx.lineTo(x + width/2, y + height/2 – 10);
ctx.fill(); ctx.stroke();
// Text
ctx.font = “bold 28px ‘Cinzel’, serif”;
ctx.fillStyle = currentTheme.ink;
ctx.textAlign = “center”;
ctx.textBaseline = “middle”;
ctx.fillText(text, x, y);
}
// — Name Generation —
const PRE = [“Aethel”, “Ald”, “Bal”, “Cor”, “Dun”, “Ea”, “Fael”, “Gor”, “Hal”, “Ili”, “Kel”, “Lor”, “Mor”, “Nor”, “Oth”, “Pel”, “Qar”, “Riv”, “Sil”, “Tor”, “Ul”, “Val”, “Win”, “Xyl”, “Yar”, “Zar”];
const SUF = [“gard”, “ia”, “ton”, “berg”, “grad”, “vale”, “dor”, “mar”, “thal”, “wyn”, “ford”, “keep”, “run”, “fall”, “wood”, “sea”, “peak”, “fast”];
function generateName() {
const p = PRE[Math.floor(rng() * PRE.length)];
const s = SUF[Math.floor(rng() * SUF.length)];
return p + s;
}
// — Main Generation Routine —
function generateMap() {
// Setup Canvas & Resolution
const resMultiplier = parseInt(document.getElementById(‘resSelect’).value);
width = 1000 * resMultiplier;
height = 700 * resMultiplier;
canvas.width = width;
canvas.height = height;
// Get Settings
const seedStr = document.getElementById(‘seedInput’).value || ‘MAP’;
// Convert string seed to integer
let seedNum = 0;
for(let i=0; i<seedStr.length; i++) seedNum += seedStr.charCodeAt(i) * Math.pow(10, i);
rng = mulberry32(seedNum);
elevNoise = new Noise(rng);
moistNoise = new Noise(rng);
const scale = parseInt(document.getElementById(‘scale’).value) * resMultiplier;
const waterLevel = parseInt(document.getElementById(‘waterLevel’).value) / 100;
const globalMoisture = parseInt(document.getElementById(‘moisture’).value) / 100;
currentTheme = THEMES[document.getElementById(‘themeSelect’).value];
const doCoastlines = document.getElementById(‘drawCoastlines’).checked;
const doSymbols = document.getElementById(‘drawSymbols’).checked;
const doPOIs = document.getElementById(‘drawPOIs’).checked;
const doGrid = document.getElementById(‘drawGrid’).checked;
const doDecor = document.getElementById(‘drawDecor’).checked;
const gridScale = resMultiplier > 1 ? 2 : 3; // Pixel size. Smaller = finer detail
// 1. Generate Maps & Draw Base Biomes
ctx.fillStyle = currentTheme.bg;
ctx.fillRect(0, 0, width, height);
const mapData = []; // Store data for passes
for (let x = 0; x < width; x += gridScale) {
mapData[x] = [];
for (let y = 0; y < height; y += gridScale) {
// Elevation
let nx = x / scale;
let ny = y / scale;
// Distance from center for island generation (Vignette)
const dx = x/width – 0.5;
const dy = y/height – 0.5;
const dist = Math.sqrt(dx*dx + dy*dy) * 2;
// Elevation noise (fbm) normalized to -1 to 1
let e = elevNoise.fbm(nx, ny, 6, 0.5, 2);
// Map to 0-1 and apply circular mask
e = (e + 1) / 2;
e = e – (dist * 0.4); // Force edges to be water
if(e < 0) e = 0;
// Moisture
let m = moistNoise.fbm(nx + 100, ny + 100, 4, 0.5, 2);
m = (m + 1) / 2;
m += globalMoisture; // Apply user offset
if(m < 0) m = 0; if(m > 1) m = 1;
const biome = getBiome(e, m);
mapData[x][y] = { e, m, biome };
// Only draw biomes if not purely monochrome, or if water in monochrome
if (document.getElementById(‘themeSelect’).value === ‘monochrome’) {
if(biome.includes(‘water’) || biome === ‘beach’) {
// Stipple effect for monochrome water
if(rng() > 0.95 && biome !== ‘deepWater’) {
ctx.fillStyle = ‘#000’;
ctx.fillRect(x, y, 1, 1);
}
}
} else {
ctx.fillStyle = currentTheme.biomes[biome];
ctx.fillRect(x, y, gridScale, gridScale);
}
}
}
// 2. Texture Overlay
if(document.getElementById(‘themeSelect’).value !== ‘monochrome’) {
ctx.globalAlpha = 0.05;
for (let i = 0; i < 10000 * resMultiplier; i++) {
ctx.fillStyle = rng() > 0.5 ? ‘#000’ : ‘#fff’;
ctx.fillRect(rng() * width, rng() * height, 2, 2);
}
ctx.globalAlpha = 1.0;
}
// 3. Coastlines (Ink Outline)
if (doCoastlines) {
ctx.strokeStyle = currentTheme.waterline;
ctx.lineWidth = 1 * resMultiplier;
ctx.beginPath();
for (let x = gridScale; x < width – gridScale; x += gridScale) {
for (let y = gridScale; y < height – gridScale; y += gridScale) {
const current = mapData[x][y].biome;
const isLand = !current.includes(‘water’);
if(isLand) {
// Check neighbors
if(mapData[x+gridScale] && mapData[x+gridScale][y] && mapData[x+gridScale][y].biome.includes(‘water’)) {
ctx.moveTo(x+gridScale, y); ctx.lineTo(x+gridScale, y+gridScale);
}
if(mapData[x-gridScale] && mapData[x-gridScale][y] && mapData[x-gridScale][y].biome.includes(‘water’)) {
ctx.moveTo(x, y); ctx.lineTo(x, y+gridScale);
}
if(mapData[x] && mapData[x][y+gridScale] && mapData[x][y+gridScale].biome.includes(‘water’)) {
ctx.moveTo(x, y+gridScale); ctx.lineTo(x+gridScale, y+gridScale);
}
if(mapData[x] && mapData[x][y-gridScale] && mapData[x][y-gridScale].biome.includes(‘water’)) {
ctx.moveTo(x, y); ctx.lineTo(x+gridScale, y);
}
}
}
}
ctx.stroke();
// Inner and outer ripple lines
ctx.globalAlpha = 0.3;
ctx.lineWidth = 0.5 * resMultiplier;
ctx.stroke();
ctx.globalAlpha = 1.0;
}
// 4. Hex Grid Overlay
if (doGrid) {
setInk(0.15);
ctx.lineWidth = 1;
const hexSize = 25 * resMultiplier;
for (let x = 0; x < width + hexSize; x += hexSize * 1.5) {
for (let y = 0; y < height + hexSize; y += hexSize * Math.sqrt(3)) {
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const angle = 2 * Math.PI / 6 * i;
const x_i = x + hexSize * Math.cos(angle);
const y_i = (y + (x / (hexSize * 1.5) % 2 === 0 ? 0 : hexSize * Math.sqrt(3)/2)) + hexSize * Math.sin(angle);
if (i === 0) ctx.moveTo(x_i, y_i);
else ctx.lineTo(x_i, y_i);
}
ctx.closePath();
ctx.stroke();
}
}
}
// 5. Symbols (Mountains, Trees) & Gather POI locations
const validPOILocations = [];
if (doSymbols) {
const step = 15 * resMultiplier; // Spacing between checks
// Need to draw top to bottom for correct z-sorting
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
// Snap to data grid
const gx = Math.floor(x / gridScale) * gridScale;
const gy = Math.floor(y / gridScale) * gridScale;
if(!mapData[gx] || !mapData[gx][gy]) continue;
const data = mapData[gx][gy];
const b = data.biome;
const jX = x + (rng() – 0.5) * step * 0.8;
const jY = y + (rng() – 0.5) * step * 0.8;
const size = (6 + rng() * 4) * resMultiplier;
if (b === ‘mountain’ || b === ‘snow’) {
if(rng() > 0.3) drawMountain(jX, jY, size * 1.5);
} else if (b === ‘forest’ || b === ‘taiga’ || b === ‘tropical’) {
if(rng() > 0.4) drawTree(jX, jY, size, b);
} else if (b === ‘desert’ || b === ‘savanna’) {
if(rng() > 0.9) drawTree(jX, jY, size * 0.8, ‘cactus’);
}
// Collect POI candidates (Flat land, not water, not mountain)
if(!b.includes(‘water’) && b !== ‘mountain’ && b !== ‘snow’) {
if(rng() > 0.95) { // Thin out candidates
validPOILocations.push({x: jX, y: jY, biome: b});
}
}
}
}
}
// 6. Points of Interest (Cities)
if (doPOIs && validPOILocations.length > 0) {
// Shuffle candidates
for (let i = validPOILocations.length – 1; i > 0; i–) {
const j = Math.floor(rng() * (i + 1));
[validPOILocations[i], validPOILocations[j]] = [validPOILocations[j], validPOILocations[i]];
}
const numCities = Math.min(Math.floor(rng() * 6) + 3, validPOILocations.length); // 3 to 8 cities
const placed = [];
for (let i = 0; i < numCities; i++) {
const loc = validPOILocations[i];
// Simple distance check to prevent overlapping text
let tooClose = false;
for(let p of placed) {
const dist = Math.hypot(p.x – loc.x, p.y – loc.y);
if(dist < 80 * resMultiplier) tooClose = true;
}
if(tooClose) continue;
const isCap = placed.length === 0; // First one is capital
drawCity(loc.x, loc.y, isCap, generateName());
placed.push(loc);
}
}
// 7. Decorations (Border, Compass, Title)
// Frame/Vignette
const grad = ctx.createRadialGradient(width/2, height/2, width*0.3, width/2, height/2, width*0.7);
grad.addColorStop(0, ‘rgba(0,0,0,0)’);
grad.addColorStop(1, document.getElementById(‘themeSelect’).value === ‘dark’ ? ‘rgba(0,0,0,0.8)’ : ‘rgba(0,0,0,0.3)’);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, width, height);
// Border
setInk(1);
ctx.lineWidth = 4 * resMultiplier;
ctx.strokeRect(10 * resMultiplier, 10 * resMultiplier, width – 20*resMultiplier, height – 20*resMultiplier);
ctx.lineWidth = 1 * resMultiplier;
ctx.strokeRect(16 * resMultiplier, 16 * resMultiplier, width – 32*resMultiplier, height – 32*resMultiplier);
if (doDecor) {
drawCompass(width – 90 * resMultiplier, height – 90 * resMultiplier, 40 * resMultiplier);
// Generate World Name
const title = `The Realm of ${generateName()}`;
drawTitleRibbon(title, width/2, 60 * resMultiplier);
}
}
function downloadMap() {
const link = document.createElement(‘a’);
link.download = `Map_${document.getElementById(‘seedInput’).value}.png`;
link.href = canvas.toDataURL(“image/png”);
link.click();
}
// Run
window.onload = init;
</script>
</body>
</html>
