diff --git a/README.md b/README.md index 843971d..67a9613 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,11 @@ This project is made from [Next](https://nextjs.org). Development practices for Biome map obtained from [Minecraft Earth Map](https://earth.motfe.net/tiles-biomes/). Land elevation, bathymetry, and real-color maps obtained from [Visible Earth](https://visibleearth.nasa.gov/). -Water mask map obtained from [Shaded Relief](https://www.shadedrelief.com/natural3/pages/extra.html). -Uses [D3](https://d3js.org/) (d3-geo) for map projections. Super Mario is a property of Nintendo. +Water mask map obtained from [Shaded Relief](https://www.shadedrelief.com/natural3/pages/extra.html). Cloud map obtained +from [Wikimedia Commons](https://commons.wikimedia.org/wiki/File:Asia_Cloud_Map.jpg). Uses [D3](https://d3js.org/) +(d3-geo) for map projections. Super Mario is a property of Nintendo. [Super Mario War](http://supermariowar.supersanctuary.net/) is a fangame inspired by Super Mario games. ## Samples -![Larger Sample Output](./docs/img/sample-output-2.png) \ No newline at end of file +![Larger Sample Output](./docs/img/sample-output-2.png) diff --git a/package.json b/package.json index ba39f48..be40c7d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@theoryofnekomata/formxtra": "^0.2.3", + "chance": "^1.1.8", "d3": "^7.3.0", "d3-geo": "^3.0.1", "d3-geo-polygon": "^1.12.1", @@ -22,6 +23,7 @@ "ts-node": "^10.7.0" }, "devDependencies": { + "@types/chance": "^1.1.3", "@types/d3": "^7.1.0", "@types/d3-geo": "^3.0.2", "@types/node": "17.0.21", diff --git a/src/assets/data/000/clouds.png b/src/assets/data/000/clouds.png new file mode 100644 index 0000000..056da44 Binary files /dev/null and b/src/assets/data/000/clouds.png differ diff --git a/src/backend/services/Biome.service.ts b/src/backend/services/Biome.service.ts index dd8eab0..73cf9d0 100644 --- a/src/backend/services/Biome.service.ts +++ b/src/backend/services/Biome.service.ts @@ -1,7 +1,9 @@ import ColorService, {ColorServiceImpl} from './Color.service'; -import {LandType} from '../../utils/types'; +import {Biome, LandType} from '../../utils/types'; export default interface BiomeService { + getBiome(x: number, y: number, width: number): Biome + getBiomeByIndex(index: number): Biome checkBiomeLandType(x: number, y: number, width: number): LandType isMountainBiome(x: number, y: number, width: number): boolean isHillBiome(x: number, y: number, width: number): boolean @@ -15,13 +17,20 @@ export class BiomeServiceImpl implements BiomeService { this.colorService = new ColorServiceImpl() } - checkBiomeLandType(x: number, y: number, width: number): LandType { - const dataIndex = ((y * width) + x) * 4; - switch (this.colorService.rgb( + getBiome(x: number, y: number, width: number): Biome { + return this.getBiomeByIndex(((y * width) + x) * 4); + } + + getBiomeByIndex(dataIndex: number): Biome { + return this.colorService.rgb( this.biomePngData[dataIndex], this.biomePngData[dataIndex + 1], this.biomePngData[dataIndex + 2], - )) { + ); + } + + checkBiomeLandType(x: number, y: number, width: number): LandType { + switch (this.getBiome(x, y, width)) { case 0xfa9418: case 0xffbc40: return LandType.DESERT; @@ -75,12 +84,7 @@ export class BiomeServiceImpl implements BiomeService { } isMountainBiome(x: number, y: number, width: number): boolean { - const dataIndex = ((y * width) + x) * 4; - switch (this.colorService.rgb( - (this.biomePngData[dataIndex]), - (this.biomePngData[dataIndex + 1]), - (this.biomePngData[dataIndex + 2]), - )) { + switch (this.getBiome(x, y, width)) { case 0x606060: case 0xa0a0a0: case 0x507050: @@ -95,17 +99,14 @@ export class BiomeServiceImpl implements BiomeService { } isHillBiome(x: number, y: number, width: number): boolean { - const dataIndex = ((y * width) + x) * 4; - switch (this.colorService.rgb( - (this.biomePngData[dataIndex]), - (this.biomePngData[dataIndex + 1]), - (this.biomePngData[dataIndex + 2]), - )) { + switch (this.getBiome(x, y, width)) { + case 0xffffff: case 0xd25f12: case 0x163933: case 0x2c4205: case 0x454f3e: case 0x687942: + case Biome.SAVANNA: return true default: break @@ -115,16 +116,16 @@ export class BiomeServiceImpl implements BiomeService { } isWoodedBiome(x: number, y: number, width: number): boolean { - const dataIndex = ((y * width) + x) * 4; - switch (this.colorService.rgb( - (this.biomePngData[dataIndex]), - (this.biomePngData[dataIndex + 1]), - (this.biomePngData[dataIndex + 2]), - )) { + switch (this.getBiome(x, y, width)) { + case 0x0b6659: + case 0x8db360: case 0x056621: case 0x537b09: case 0x596651: case 0x2d8e49: + case 0x628b17: + case Biome.DESERT: + case Biome.GIANT_TREE_TAIGA: return true; default: break diff --git a/src/backend/services/Projection.service.ts b/src/backend/services/Projection.service.ts index 2c7015c..125fe74 100644 --- a/src/backend/services/Projection.service.ts +++ b/src/backend/services/Projection.service.ts @@ -1,5 +1,5 @@ import {PNG} from 'pngjs'; -import {Projection} from '../../utils/types'; +import {Bounds, Projection} from '../../utils/types'; import * as d3geo from 'd3-geo'; type ProjectionData = { @@ -8,7 +8,7 @@ type ProjectionData = { } export default interface ProjectionService { - project(equiImage: PNG, projection: Projection): PNG | null + project(equiImage: PNG, projection: Projection, bounds: Bounds): PNG | null } export class ProjectionServiceImpl implements ProjectionService { @@ -33,14 +33,14 @@ export class ProjectionServiceImpl implements ProjectionService { private readonly referenceWidth = 960 as const; - project(equiImage: PNG, projection: Projection): PNG | null { + project(equiImage: PNG, projection: Projection, bounds: Bounds): PNG | null { if (projection === Projection.EQUIRECTANGULAR) { return equiImage; } const { [projection]: currentProjectionData } = this.projections const baseProjection = currentProjectionData.fn(); - const modifiedProjection = baseProjection.center([0, 0]); + const modifiedProjection = baseProjection.center([(bounds[0][0] + bounds[1][0]) / 2, (bounds[0][1] + bounds[1][1]) / 2]); const { invert } = modifiedProjection; if (!invert) { return null; diff --git a/src/backend/services/Tile.service.ts b/src/backend/services/Tile.service.ts index bf7e3da..a3fe7cc 100644 --- a/src/backend/services/Tile.service.ts +++ b/src/backend/services/Tile.service.ts @@ -1,5 +1,7 @@ import BiomeService, {BiomeServiceImpl} from './Biome.service'; -import {LandType, WaterType} from '../../utils/types'; +import {Biome, LandType, WaterType} from '../../utils/types'; +import Chance from 'chance'; +import ColorService, {ColorServiceImpl} from './Color.service'; export type NeighborData = { nw: boolean, @@ -20,6 +22,9 @@ export default interface TileService { } export class TileServiceImpl implements TileService { + private readonly rng: Chance.Chance; + private readonly colorService: ColorService; + private index(x: number, y: number, width: number) { return ((y * width) + x) * 4; } @@ -199,27 +204,91 @@ export class TileServiceImpl implements TileService { switch (landType) { case LandType.DESERT: case LandType.MESA: + if (!this.rng.bool({ likelihood: 80 })) { + return 0 + } return 796; case LandType.ICE: + if (!this.rng.bool({ likelihood: 20 })) { + return 0 + } return 799; case LandType.FOREST: case LandType.PLAINS: default: - return 797; + break } + if (!this.rng.bool({ likelihood: 80 })) { + return 0 + } + return 797; + } - private getHillForegroundTileType(landType: LandType): number { + private getHillForegroundTileType(landType: LandType, index: number): number { if (landType === LandType.ICE) { + if (!this.rng.bool({ likelihood: 30 })) { + return 0 + } return 903 } + + switch(this.biomeService.getBiomeByIndex(index)) { + case Biome.SAVANNA: + if (!this.rng.bool({ likelihood: 30 })) { + return 0 + } + return this.rng.bool() ? 901 : 902; + default: + break; + } + + if (!this.rng.bool({ likelihood: 70 })) { + return 0 + } return 901 } - private getWoodedForegroundTileType(landType: LandType): number { + private getWoodedForegroundTileType(landType: LandType, index: number): number { + if (landType === LandType.DESERT) { + if (this.rng.bool({ likelihood: 5 })) { + return 904; + } + return 0; + } + if (landType === LandType.ICE) { + if (!this.rng.bool({ likelihood: 30 })) { + return 0 + } return 906 } + + switch (this.biomeService.getBiomeByIndex(index)) { + case Biome.JUNGLE: + case Biome.JUNGLE_EDGE: + if (!this.rng.bool({ likelihood: 60 })) { + return 0 + } + return 904; + case Biome.FOREST: + if (!this.rng.bool({ likelihood: 60 })) { + return 0 + } + return 907; + case Biome.TAIGA: + case Biome.GIANT_TREE_TAIGA: + if (!this.rng.bool({ likelihood: 20 })) { + return 0 + } + return 908; + default: + break; + } + + if (!this.rng.bool({ likelihood: 50 })) { + return 0 + } return 905 } @@ -228,8 +297,11 @@ export class TileServiceImpl implements TileService { private readonly elevaionPngData: Buffer, private readonly waterMaskPngData: Buffer, private readonly bathymetryPngData: Buffer, + private readonly cloudsPngData: Buffer, ) { this.biomeService = new BiomeServiceImpl(biomePngData) + this.colorService = new ColorServiceImpl() + this.rng = Chance(69420); } determineBackgroundTileType(x: number, y: number, width: number, height: number): number { @@ -239,9 +311,14 @@ export class TileServiceImpl implements TileService { } private determineLandForegroundTileType(x: number, y: number, dx: number): number { + const i = this.index(x, y, dx); + + if (this.cloudsPngData[i] >= 0x20 && this.rng.bool({ likelihood: 10 })) { + return 790; + } + const landType = this.biomeService.checkBiomeLandType(x, y, dx); - const i = this.index(x, y, dx); const isMountain = this.elevaionPngData[i] >= 0x40 || this.biomeService.isMountainBiome( x, y, @@ -252,17 +329,23 @@ export class TileServiceImpl implements TileService { } if (this.biomeService.isHillBiome(x, y, dx)) { - return this.getHillForegroundTileType(landType) + return this.getHillForegroundTileType(landType, i); } if (this.biomeService.isWoodedBiome(x, y, dx)) { - return this.getWoodedForegroundTileType(landType); + return this.getWoodedForegroundTileType(landType, i); } return 0; } private determineWaterForegroundTileType(x: number, y: number, dx: number): number { + const i = this.index(x, y, dx); + + if (this.cloudsPngData[i] >= 0x20 && this.rng.bool({ likelihood: 20 })) { + return 790; + } + return 0; } diff --git a/src/components/GenerateMapForm/index.tsx b/src/components/GenerateMapForm/index.tsx index 7be05ef..f45cf34 100644 --- a/src/components/GenerateMapForm/index.tsx +++ b/src/components/GenerateMapForm/index.tsx @@ -5,7 +5,13 @@ import {NumericInput} from '../NumericInput'; import styles from '../../styles/components/GenerateMapForm/index.module.css'; import {ActionButton} from '../ActionButton'; -export const GenerateMapForm: VFC = () => { +type GenerateMapFormProps = { + disabled?: boolean; +} + +export const GenerateMapForm: VFC = ({ + disabled = false, +}) => { return (
{ >
Base @@ -150,6 +157,7 @@ export const GenerateMapForm: VFC = () => { >
Cities @@ -162,6 +170,7 @@ export const GenerateMapForm: VFC = () => { >
Map @@ -206,6 +215,7 @@ export const GenerateMapForm: VFC = () => { > Generate Map diff --git a/src/pages/api/generate/index.ts b/src/pages/api/generate/index.ts index 086ca2e..0244e15 100644 --- a/src/pages/api/generate/index.ts +++ b/src/pages/api/generate/index.ts @@ -1,15 +1,16 @@ import {NextApiHandler} from 'next'; import * as fs from 'fs/promises'; import {PNG} from 'pngjs'; -import {MapType, Projection, WorldData} from '../../../utils/types'; +import {Bounds, MapType, Projection, WorldData} from '../../../utils/types'; import {Stats} from 'fs'; import sharp from 'sharp'; import ProjectionService, {ProjectionServiceImpl} from '../../../backend/services/Projection.service'; import {FileSystemServiceImpl} from '../../../backend/services/FileSystem.service'; import TileService, {TileServiceImpl} from '../../../backend/services/Tile.service'; -const generateProjectedBaseData = async (t: MapType, p: Projection) => { - const destPath = `public/generated/base/${p}-${t}.png`; +const stringifyBounds = (bounds: Bounds) => bounds.map(b => b.map(c => Math.floor(c).toString()).join('_')).join('_'); +const generateProjectedBaseData = async (t: MapType, p: Projection, bounds: Bounds) => { + const destPath = `public/generated/base/${p}_${t}_${stringifyBounds(bounds)}.png`; let stat: Stats | undefined; let shouldGenerateFile = false; try { @@ -27,7 +28,7 @@ const generateProjectedBaseData = async (t: MapType, p: Projection) => { const inputBuffer = await fs.readFile(`src/assets/data/000/${t}.png`) const inputPng = PNG.sync.read(inputBuffer); const projectionService: ProjectionService = new ProjectionServiceImpl() - const outputPng = projectionService.project(inputPng, p) as PNG; + const outputPng = projectionService.project(inputPng, p, bounds) as PNG; const outputBuffer = PNG.sync.write(outputPng); await fs.writeFile(destPath, outputBuffer); } @@ -39,6 +40,7 @@ const getResizeKernel = (t: MapType) => { switch (t) { case MapType.BIOME: case MapType.BATHYMETRY: + case MapType.CLOUDS: return 'nearest'; case MapType.WATER_MASK: return 'lanczos2'; @@ -48,9 +50,9 @@ const getResizeKernel = (t: MapType) => { return 'cubic'; } -const resizeBaseData = async (t: MapType, p: Projection, size: number, isWidthDimension: boolean) => { - const destPath = `public/generated/resized/${isWidthDimension ? 'w' : 'h'}-${size}-${p}-${t}.png`; - let resizeChain = sharp(`public/generated/base/${p}-${t}.png`) +const resizeBaseData = async (t: MapType, p: Projection, bounds: Bounds, size: number, isWidthDimension: boolean) => { + const destPath = `public/generated/resized/${isWidthDimension ? 'w' : 'h'}_${size}_${p}_${t}_${stringifyBounds(bounds)}.png`; + let resizeChain = sharp(`public/generated/base/${p}_${t}_${stringifyBounds(bounds)}.png`) .resize({ [isWidthDimension ? 'width' : 'height']: size, kernel: getResizeKernel(t), @@ -68,18 +70,18 @@ const resizeBaseData = async (t: MapType, p: Projection, size: number, isWidthDi return destPath; } -const generateWorldData = async (p: Projection, size: number, isWidthDimension: boolean) => { +const generateWorldData = async (p: Projection, bounds: Bounds, size: number, isWidthDimension: boolean) => { await FileSystemServiceImpl.ensureDirectory('public/generated/resized'); const tempResizedFiles = await Promise.all( Object .values(MapType) - .map(async (t: MapType) => resizeBaseData(t, p, size, isWidthDimension)) + .map(async (t: MapType) => resizeBaseData(t, p, bounds, size, isWidthDimension)) ); const mapTypeBuffers = await Promise.all(Object.values(MapType).map(async (t: MapType) => { return { mapType: t, - buffer: await fs.readFile(`public/generated/resized/${isWidthDimension ? 'w' : 'h'}-${size}-${p}-${t}.png`), + buffer: await fs.readFile(`public/generated/resized/${isWidthDimension ? 'w' : 'h'}_${size}_${p}_${t}_${stringifyBounds(bounds)}.png`), }; })) @@ -116,7 +118,8 @@ const generateWorldData = async (p: Projection, size: number, isWidthDimension: data[MapType.BIOME].data, data[MapType.ELEVATION].data, data[MapType.WATER_MASK].data, - data[MapType.BATHYMETRY].data + data[MapType.BATHYMETRY].data, + data[MapType.CLOUDS].data, ) let i = 0; @@ -165,21 +168,24 @@ const generateHandler: NextApiHandler = async (req, res) => { projection = Projection.EQUIRECTANGULAR, width, height, - bounds = '-180,90;180,-90', + bounds: rawBounds = '-180,90;180,-90', } = req.query const { size, isWidthDimension } = determineDimension( Array.isArray(width) ? width[0] : width, Array.isArray(height) ? height[0] : height, ); + const bounds = rawBounds as string; + const boundsParsed = bounds.split(';').map((b) => b.split(',').map(c => Number(c))) as Bounds; + await ensureDirectories(); const baseDataImageUrls = await Promise.all( Object .values(MapType) - .map(async (t: MapType) => generateProjectedBaseData(t, projection as Projection)) + .map(async (t: MapType) => generateProjectedBaseData(t, projection as Projection, boundsParsed)) ); - const worldData = await generateWorldData(projection as Projection, size, isWidthDimension); + const worldData = await generateWorldData(projection as Projection, boundsParsed, size, isWidthDimension); res.json({ baseDataImageUrls, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 4646303..ec767ee 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -18,22 +18,6 @@ import WORLD_FOREGROUND from '../assets/gfx/world_foreground.png'; import {DropdownSelect} from '../components/DropdownSelect'; import {ActionButton} from '../components/ActionButton'; -const IMAGE_URLS_INDEX = [ - MapType.BIOME, - MapType.ELEVATION, - MapType.WATER_MASK, - MapType.BATHYMETRY, - MapType.REAL_COLOR, -] - -const IMAGE_STACKING = [ - MapType.WATER_MASK, - MapType.BATHYMETRY, - MapType.ELEVATION, - MapType.REAL_COLOR, - MapType.BIOME, -] - const loadImage = async (src: string, processTransparency = false) => { return new Promise((resolve, reject) => { const originalImage = new Image() @@ -235,8 +219,9 @@ ${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',' const IndexPage: NextPage = () => { const [data, setData] = useState() + const [loading, setLoading] = useState(false) const [previewMapOpacity, setPreviewMapOpacity] = useState>(() => { - return IMAGE_URLS_INDEX.reduce( + return Object.values(MapType).reduce( (theValue, mapType) => { return { ...theValue, @@ -252,11 +237,11 @@ const IndexPage: NextPage = () => { const scrollRef = useRef(false) const handleSubmit: FormEventHandler = async (e) => { e.preventDefault() - const form = e.target as HTMLFormElement - const values = getFormValues(form) + const values = getFormValues(e.currentTarget) const url = new URL('/api/generate', 'http://localhost:3000'); const search = new URLSearchParams(values) url.search = search.toString() + setLoading(true) const response = await fetch(url.toString(), { method: 'GET', headers: { @@ -267,6 +252,7 @@ const IndexPage: NextPage = () => { if (response.ok) { setData(responseData) } + setLoading(false) } const saveScreenshot: MouseEventHandler = async (e) => { @@ -373,7 +359,7 @@ const IndexPage: NextPage = () => { className={styles.form} onSubmit={handleSubmit} > - +
{ ref={baseMapScrollRef} > { - IMAGE_STACKING.map((v: MapType) => ( + Object.values(MapType).map((v: MapType, index: number) => ( {v} { className={styles.mapForm} > { - IMAGE_STACKING.map((v: MapType) => ( + Object.values(MapType).map((v: MapType) => (
diff --git a/src/utils/types.ts b/src/utils/types.ts index 1bc53b1..a6af625 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -6,10 +6,11 @@ export enum Projection { } export enum MapType { - BIOME = 'biomes', - ELEVATION = 'elevation', WATER_MASK = 'water-mask', BATHYMETRY = 'bathymetry', + ELEVATION = 'elevation', + CLOUDS = 'clouds', + BIOME = 'biomes', REAL_COLOR = 'real-color', } @@ -45,3 +46,47 @@ export enum LandType { BEACH = 5, MUD = 6, } + +export type Coords = [number, number]; +export type Bounds = [Coords, Coords]; + +export enum Biome { + OCEAN = 0x000070, + PLAINS = 0x8db360, + DESERT = 0xfa9418, + MOUNTAINS = 0x606060, + FOREST = 0x056621, + TAIGA = 0x0b6659, + SWAMP = 0x07f9b2, + RIVER = 0x0000ff, + SNOWY_TUNDRA = 0xffffff, + SNOWY_MOUNTAINS = 0xa0a0a0, + BEACH = 0xfade55, + DESERT_HILLS = 0xd25f12, + TAIGA_HILLS = 0x163933, + JUNGLE = 0x537b09, + JUNGLE_HILLS = 0x2c4205, + JUNGLE_EDGE = 0x628b17, + SNOWY_BEACH = 0xfaf0c0, + GIANT_TREE_TAIGA = 0x596651, + GIANT_TREE_TAIGA_HILLS = 0x454f3e, + WOODED_MOUNTAINS = 0x507050, + SAVANNA = 0xbdb25f, + SAVANNA_PLATEAU = 0xa79d64, + BADLANDS = 0xd94515, + BADLANDS_PLATEAU = 0xca8c65, + WARM_OCEAN = 0x0000ac, + LUKEWARM_OCEAN = 0x000090, + COLD_OCEAN = 0x202070, + DEEP_WARM_OCEAN = 0x000050, + DEEP_LUKEWARM_OCEAN = 0x000040, + DEEP_COLD_OCEAN = 0x202038, + DEEP_FROZEN_OCEAN = 0x404090, + SUNFLOWER_PLAINS = 0xb5db88, + DESERT_LAKES = 0xffbc40, + FLOWER_FOREST = 0x2d8e49, + TAIGA_MOUNTAINS = 0x338e81, + MODIFIED_JUNGLE_EDGE = 0x8ab33f, + DARK_FOREST_HILLS = 0x687942, + SNOWY_TAIGA_MOUNTAINS = 0x597d72, +} diff --git a/yarn.lock b/yarn.lock index c385cff..b35615f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -176,6 +176,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/chance@^1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.3.tgz#d19fe9391288d60fdccd87632bfc9ab2b4523fea" + integrity sha512-X6c6ghhe4/sQh4XzcZWSFaTAUOda38GQHmq9BUanYkOE/EO7ZrkazwKmtsj3xzTjkLWmwULE++23g3d3CCWaWw== + "@types/d3-array@*": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.2.tgz#71c35bca8366a40d1b8fce9279afa4a77fb0065d" @@ -668,6 +673,11 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chance@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.8.tgz#5d6c2b78c9170bf6eb9df7acdda04363085be909" + integrity sha512-v7fi5Hj2VbR6dJEGRWLmJBA83LJMS47pkAbmROFxHWd9qmE1esHRZW8Clf1Fhzr3rjxnNZVCjOEv/ivFxeIMtg== + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"