@@ -0,0 +1,3 @@ | |||
{ | |||
"extends": "next/core-web-vitals" | |||
} |
@@ -0,0 +1,42 @@ | |||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
# dependencies | |||
/node_modules | |||
/.pnp | |||
.pnp.js | |||
# testing | |||
/coverage | |||
# next.js | |||
/.next/ | |||
/out/ | |||
# production | |||
/build | |||
# misc | |||
.DS_Store | |||
*.pem | |||
# debug | |||
npm-debug.log* | |||
yarn-debug.log* | |||
yarn-error.log* | |||
.pnpm-debug.log* | |||
# local env files | |||
.env.local | |||
.env.development.local | |||
.env.test.local | |||
.env.production.local | |||
# vercel | |||
.vercel | |||
# typescript | |||
*.tsbuildinfo | |||
public/generated/ | |||
public/worlds/ | |||
.idea/ |
@@ -0,0 +1,37 @@ | |||
# real-worldgen | |||
Generate Super Mario War worlds from real-world data. | |||
## Requirements | |||
- Node.js (preferably latest) | |||
- Yarn | |||
## Setup | |||
1. Install dependencies: | |||
```shell | |||
yarn [install] | |||
``` | |||
2. Build the app: | |||
```shell | |||
yarn build | |||
``` | |||
3. Run the app on `http://localhost:3000`: | |||
```shell | |||
yarn start | |||
``` | |||
## Development | |||
This project is made from [Next](https://nextjs.org). Development practices for Next are applicable for this project. | |||
## Credits | |||
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. |
@@ -0,0 +1 @@ | |||
declare module 'd3-geo-polygon'; |
@@ -0,0 +1,5 @@ | |||
/// <reference types="next" /> | |||
/// <reference types="next/image-types/global" /> | |||
// NOTE: This file should not be edited | |||
// see https://nextjs.org/docs/basic-features/typescript for more information. |
@@ -0,0 +1,6 @@ | |||
/** @type {import('next').NextConfig} */ | |||
const nextConfig = { | |||
reactStrictMode: true, | |||
} | |||
module.exports = nextConfig |
@@ -0,0 +1,35 @@ | |||
{ | |||
"name": "real-worldgen", | |||
"version": "0.1.0", | |||
"private": true, | |||
"scripts": { | |||
"dev": "next dev", | |||
"build": "next build", | |||
"start": "next start", | |||
"lint": "next lint", | |||
"link-gfx": "ts-node --project ./tsconfig.script.json ./scripts/link-gfx.ts" | |||
}, | |||
"dependencies": { | |||
"@theoryofnekomata/formxtra": "^0.2.3", | |||
"d3": "^7.3.0", | |||
"d3-geo": "^3.0.1", | |||
"d3-geo-polygon": "^1.12.1", | |||
"next": "12.1.0", | |||
"pngjs": "^6.0.0", | |||
"react": "17.0.2", | |||
"react-dom": "17.0.2", | |||
"sharp": "^0.30.2", | |||
"ts-node": "^10.7.0" | |||
}, | |||
"devDependencies": { | |||
"@types/d3": "^7.1.0", | |||
"@types/d3-geo": "^3.0.2", | |||
"@types/node": "17.0.21", | |||
"@types/pngjs": "^6.0.1", | |||
"@types/react": "17.0.39", | |||
"@types/sharp": "^0.30.0", | |||
"eslint": "8.10.0", | |||
"eslint-config-next": "12.1.0", | |||
"typescript": "4.6.2" | |||
} | |||
} |
@@ -0,0 +1,4 @@ | |||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none" | |||
xmlns="http://www.w3.org/2000/svg"> | |||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/> | |||
</svg> |
@@ -0,0 +1,28 @@ | |||
import * as fs from 'fs/promises'; | |||
import * as path from 'path'; | |||
const linkGfx = async () => { | |||
const target = path.resolve('../../data/gfx/packs/Classic/world/preview'); | |||
const link = path.resolve('./src/assets/gfx') | |||
process.stdout.write(`Making link:\n${link} -> ${target}\n`); | |||
try { | |||
await fs.stat(link) | |||
process.stdout.write('Link exists. Overwriting...\n'); | |||
await fs.unlink(link) | |||
} catch (e) { | |||
// noop | |||
} | |||
try { | |||
await fs.symlink(target, link, 'dir'); | |||
process.stdout.write('Link created.\n'); | |||
} catch (err) { | |||
console.log(err); | |||
process.stderr.write('Cannot create link.\n') | |||
process.exit(1); | |||
return; | |||
} | |||
process.exit(0); | |||
} | |||
void linkGfx() |
@@ -0,0 +1 @@ | |||
D:/Apps/Games/Super Mario War/data/gfx/packs/Classic/world/preview |
@@ -0,0 +1,22 @@ | |||
import {Stats} from 'fs'; | |||
import fs from 'fs/promises'; | |||
export default interface FileSystemService {} | |||
export class FileSystemServiceImpl implements FileSystemService { | |||
static async ensureDirectory(generatedBaseDataBasePath: string) { | |||
let stat: Stats | undefined; | |||
try { | |||
stat = await fs.stat(generatedBaseDataBasePath) | |||
} catch (errRaw) { | |||
const err = errRaw as { code: string }; | |||
if (err.code !== 'ENOENT') { | |||
throw err; | |||
} | |||
await fs.mkdir(generatedBaseDataBasePath); | |||
} | |||
if (stat && !stat.isDirectory()) { | |||
throw new Error(`Destination directory is misconfigured, check if ${generatedBaseDataBasePath} is a directory`); | |||
} | |||
} | |||
} |
@@ -0,0 +1,57 @@ | |||
import {PNG} from 'pngjs'; | |||
import {Projection} from '../../utils/types'; | |||
import * as d3geo from 'd3-geo'; | |||
export default interface ProjectionService {} | |||
export class ProjectionServiceImpl implements ProjectionService { | |||
private readonly projections: Record<Projection, Function> = { | |||
[Projection.EQUIRECTANGULAR]: d3geo.geoEquirectangular, | |||
[Projection.MERCATOR]: d3geo.geoMercator, | |||
} as const | |||
private readonly referenceWidth = 960 as const; | |||
project(equiImage: PNG, projection: Projection) { | |||
if (projection === Projection.EQUIRECTANGULAR) { | |||
return equiImage; | |||
} | |||
const projectionFunction = this.projections[projection](); | |||
if (!projectionFunction.invert) { | |||
return undefined; | |||
} | |||
const sx = equiImage.width; | |||
const sy = equiImage.height; | |||
const sourceData = equiImage.data; | |||
const tx = sx; | |||
// const py = projection === Projection.MERCATOR ? dy * 2 : dy; | |||
const ty = sy; | |||
const target = new PNG({ | |||
width: tx, | |||
height: ty, | |||
}); | |||
const targetData = target.data; | |||
let i = 0; | |||
for (let y = 0; y < sy; y += 1) { | |||
for (let x = 0; x < sx; x += 1) { | |||
const projected = projectionFunction.invert([x / sx * this.referenceWidth, y / sx * this.referenceWidth]) as [number, number]; | |||
if (projected) { | |||
const [lambda, phi] = projected; | |||
if (!(lambda > 180 || lambda < -180 || phi > 90 || phi < -90)) { | |||
const q = ((90 - phi) / 180 * sy | 0) * sx + ((180 + lambda) / 360 * sx | 0) << 2; | |||
targetData[i] = sourceData[q]; | |||
targetData[i + 1] = sourceData[q + 1]; | |||
targetData[i + 2] = sourceData[q + 2]; | |||
targetData[i + 3] = 255; | |||
} | |||
} | |||
i += 4; | |||
} | |||
} | |||
return target; | |||
} | |||
} | |||
@@ -0,0 +1,28 @@ | |||
import {FC, HTMLProps} from 'react'; | |||
type ActionButtonProps = Omit<HTMLProps<HTMLButtonElement>, 'type'> & { | |||
type?: 'button' | 'reset' | 'submit', | |||
block?: boolean, | |||
} | |||
export const ActionButton: FC<ActionButtonProps> = ({ | |||
block = false, | |||
style = {}, | |||
children, | |||
...etcProps | |||
}) => { | |||
return ( | |||
<button | |||
{...etcProps} | |||
style={{ | |||
...style, | |||
display: block ? 'grid' : style?.display, | |||
width: block ? '100%' : style?.width, | |||
placeContent: 'center', | |||
height: '3rem', | |||
}} | |||
> | |||
{children} | |||
</button> | |||
) | |||
} |
@@ -0,0 +1,25 @@ | |||
import {HTMLProps, VFC} from 'react'; | |||
type CheckboxProps = Omit<HTMLProps<HTMLInputElement>, 'type'> & { | |||
label?: string, | |||
} | |||
export const Checkbox: VFC<CheckboxProps> = ({ | |||
label, | |||
...etcProps | |||
}) => { | |||
return ( | |||
<div> | |||
<label> | |||
<input | |||
type="checkbox" | |||
{...etcProps} | |||
/> | |||
{' '} | |||
<span> | |||
{label} | |||
</span> | |||
</label> | |||
</div> | |||
) | |||
} |
@@ -0,0 +1,71 @@ | |||
import {HTMLProps, VFC} from 'react'; | |||
type Option = { | |||
label: string, | |||
value: string | number | readonly string[], | |||
children?: Option[], | |||
} | |||
type DropdownSelectProps = Omit<HTMLProps<HTMLSelectElement>, 'children'> & { | |||
options?: Option[], | |||
block?: boolean, | |||
} | |||
const DropdownSelectChildren: VFC<Pick<DropdownSelectProps, 'options'>> = ({ | |||
options, | |||
}) => { | |||
if (options) { | |||
return ( | |||
<> | |||
{options.map(o => { | |||
if (o.children) { | |||
return ( | |||
<optgroup | |||
key={`${o.value}:${o.label}`} | |||
label={o.label} | |||
> | |||
<DropdownSelectChildren | |||
options={o.children} | |||
/> | |||
</optgroup> | |||
) | |||
} | |||
return ( | |||
<option | |||
key={`${o.value}:${o.label}`} | |||
value={o.value} | |||
> | |||
{o.label} | |||
</option> | |||
) | |||
})} | |||
</> | |||
) | |||
} | |||
return null | |||
} | |||
export const DropdownSelect: VFC<DropdownSelectProps> = ({ | |||
options, | |||
block = false, | |||
style = {}, | |||
...etcProps | |||
}) => { | |||
return ( | |||
<select | |||
{...etcProps} | |||
style={{ | |||
...style, | |||
display: block ? 'block' : style?.display, | |||
width: block ? '100%' : style?.width, | |||
height: '2rem', | |||
}} | |||
> | |||
<DropdownSelectChildren | |||
options={options} | |||
/> | |||
</select> | |||
) | |||
} |
@@ -0,0 +1,216 @@ | |||
import {VFC} from 'react'; | |||
import {DropdownSelect} from '../DropdownSelect'; | |||
import {Checkbox} from '../Checkbox'; | |||
import {NumericInput} from '../NumericInput'; | |||
import styles from '../../styles/components/GenerateMapForm/index.module.css'; | |||
import {ActionButton} from '../ActionButton'; | |||
import {MapType} from '../../utils/types'; | |||
export const GenerateMapForm: VFC = () => { | |||
return ( | |||
<div | |||
className={styles.base} | |||
> | |||
<div | |||
className={styles.fieldsetWrapper} | |||
> | |||
<fieldset | |||
className={styles.fieldset} | |||
> | |||
<legend> | |||
Base | |||
</legend> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<DropdownSelect | |||
name="map" | |||
block | |||
options={[ | |||
{ | |||
label: 'World', | |||
value: 'world', | |||
}, | |||
{ | |||
label: 'Multiple Continents', | |||
value: 'multiple-continents', | |||
children: [ | |||
{ | |||
label: 'Afro-Eurasia', | |||
value: 'multiple-continents.afro-eurasia', | |||
}, | |||
{ | |||
label: 'Americas', | |||
value: 'multiple-continents.americas', | |||
}, | |||
{ | |||
label: 'Eurasia', | |||
value: 'multiple-continents.eurasia', | |||
} | |||
] | |||
}, | |||
{ | |||
label: 'Single Continents', | |||
value: 'single-continents', | |||
children: [ | |||
{ | |||
label: 'Africa', | |||
value: 'single-continents.africa', | |||
}, | |||
{ | |||
label: 'Antarctica', | |||
value: 'single-continents.antarctica', | |||
}, | |||
{ | |||
label: 'Asia', | |||
value: 'single-continents.asia', | |||
}, | |||
{ | |||
label: 'Australia & Oceania', | |||
value: 'single-continents.australia-oceania', | |||
}, | |||
{ | |||
label: 'Europe', | |||
value: 'single-continents.europe', | |||
}, | |||
{ | |||
label: 'North America', | |||
value: 'single-continents.north-america', | |||
}, | |||
{ | |||
label: 'South America', | |||
value: 'single-continents.south-america', | |||
}, | |||
], | |||
}, | |||
]} | |||
/> | |||
</div> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<DropdownSelect | |||
name="projection" | |||
block | |||
options={[ | |||
{ | |||
label: 'Equirectangular', | |||
value: 'equirectangular', | |||
}, | |||
{ | |||
label: 'Mercator', | |||
value: 'mercator', | |||
}, | |||
]} | |||
/> | |||
</div> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<DropdownSelect | |||
name="terrain" | |||
block | |||
options={[ | |||
{ | |||
label: 'Normal (with shallow waters)', | |||
value: 'normal-shallow-waters', | |||
}, | |||
{ | |||
label: 'Normal (uniform waters)', | |||
value: 'normal-uniform-waters', | |||
}, | |||
{ | |||
label: 'Lava', | |||
value: 'lava', | |||
}, | |||
]} | |||
/> | |||
</div> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<Checkbox | |||
label="Force Island" | |||
defaultChecked | |||
name="forceIsland" | |||
/> | |||
</div> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<Checkbox | |||
label="Add Natural Objects" | |||
defaultChecked | |||
name="addNaturalObjects" | |||
/> | |||
</div> | |||
</fieldset> | |||
</div> | |||
<div | |||
className={styles.cities} | |||
> | |||
<fieldset | |||
className={styles.fieldset} | |||
> | |||
<legend> | |||
Cities | |||
</legend> | |||
</fieldset> | |||
</div> | |||
<div | |||
className={styles.fieldsetWrapper} | |||
> | |||
<fieldset | |||
className={styles.fieldset} | |||
> | |||
<legend> | |||
Map | |||
</legend> | |||
<div | |||
className={styles.horizontalFormGroup} | |||
> | |||
<div> | |||
<NumericInput | |||
min={1} | |||
block | |||
placeholder="Width" | |||
name="width" | |||
defaultValue={512} | |||
/> | |||
</div> | |||
<div> | |||
<NumericInput | |||
min={1} | |||
block | |||
placeholder="Height" | |||
readOnly | |||
name="height" | |||
/> | |||
</div> | |||
</div> | |||
<div | |||
className={styles.formGroup} | |||
> | |||
<div> | |||
<NumericInput | |||
block | |||
placeholder="Aspect Ratio" | |||
readOnly | |||
/> | |||
</div> | |||
</div> | |||
</fieldset> | |||
</div> | |||
<div | |||
className={styles.fieldsetWrapper} | |||
> | |||
<ActionButton | |||
block | |||
> | |||
Generate Map | |||
</ActionButton> | |||
</div> | |||
</div> | |||
) | |||
} |
@@ -0,0 +1,26 @@ | |||
import {HTMLProps, VFC} from 'react'; | |||
type NumericInputProps = Omit<HTMLProps<HTMLInputElement>, 'type'> & { | |||
block?: boolean, | |||
} | |||
export const NumericInput: VFC<NumericInputProps> = ({ | |||
block = false, | |||
style = {}, | |||
...etcProps | |||
}) => { | |||
return ( | |||
<div> | |||
<input | |||
{...etcProps} | |||
type="number" | |||
style={{ | |||
...style, | |||
display: block ? 'block' : style?.display, | |||
width: block ? '100%' : style?.width, | |||
height: '2rem', | |||
}} | |||
/> | |||
</div> | |||
) | |||
} |
@@ -0,0 +1,8 @@ | |||
import '../styles/globals.css' | |||
import type { AppProps } from 'next/app' | |||
const App = ({ Component, pageProps }: AppProps) => ( | |||
<Component {...pageProps} /> | |||
) | |||
export default App |
@@ -0,0 +1,507 @@ | |||
import {NextApiHandler} from 'next'; | |||
import * as fs from 'fs/promises'; | |||
import {PNG} from 'pngjs'; | |||
import {MapType, Projection, WaterType, WorldData} from '../../../utils/types'; | |||
import {Stats} from 'fs'; | |||
import sharp from 'sharp'; | |||
import {ProjectionServiceImpl} from '../../../backend/services/Projection.service'; | |||
import {FileSystemServiceImpl} from '../../../backend/services/FileSystem.service'; | |||
const generateProjectedBaseData = async (t: MapType, p: Projection) => { | |||
const destPath = `public/generated/base/${p}-${t}.png`; | |||
let stat: Stats | undefined; | |||
let shouldGenerateFile = false; | |||
try { | |||
stat = await fs.stat(destPath); | |||
shouldGenerateFile = !(stat && stat.isFile()); | |||
} catch (errRaw) { | |||
const err = errRaw as { code: string }; | |||
if (err.code !== 'ENOENT') { | |||
throw err; | |||
} | |||
shouldGenerateFile = true; | |||
} | |||
if (shouldGenerateFile) { | |||
const inputBuffer = await fs.readFile(`src/assets/data/000/${t}.png`) | |||
const inputPng = PNG.sync.read(inputBuffer); | |||
const projectionService = new ProjectionServiceImpl() | |||
const outputPng = projectionService.project(inputPng, p) as PNG; | |||
const outputBuffer = PNG.sync.write(outputPng); | |||
await fs.writeFile(destPath, outputBuffer); | |||
} | |||
return destPath.replace('public/', '/'); | |||
} | |||
const getResizeKernel = (t: MapType) => { | |||
switch (t) { | |||
case MapType.BIOME: | |||
case MapType.BATHYMETRY: | |||
return 'nearest'; | |||
case MapType.WATER_MASK: | |||
return 'lanczos2'; | |||
default: | |||
break; | |||
} | |||
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`) | |||
.resize({ | |||
[isWidthDimension ? 'width' : 'height']: size, | |||
kernel: getResizeKernel(t), | |||
}); | |||
if (t === MapType.WATER_MASK) { | |||
resizeChain = resizeChain.sharpen() | |||
} | |||
const buffer = await resizeChain | |||
.png() | |||
.toBuffer(); | |||
await fs.writeFile(destPath, buffer); | |||
return destPath.replace('public/', '/'); | |||
} | |||
enum LandType { | |||
MESA = 0, | |||
DESERT = 1, | |||
FOREST = 2, | |||
PLAINS = 3, | |||
ICE = 4, | |||
BEACH = 5, | |||
MUD = 6, | |||
} | |||
const rgb = (r: number, g: number, b: number) => { | |||
return (r << 16) + (g << 8) + b | |||
} | |||
const checkBiomeLandType = (biomePngData: Buffer, x: number, y: number, width: number) => { | |||
const dataIndex = ((y * width) + x) * 4; | |||
switch (rgb( | |||
biomePngData[dataIndex], | |||
biomePngData[dataIndex + 1], | |||
biomePngData[dataIndex + 2], | |||
)) { | |||
case 0xfa9418: | |||
case 0xffbc40: | |||
return LandType.DESERT; | |||
case 0x606060: | |||
case 0xd25f12: | |||
case 0xd94515: | |||
case 0xca8c65: | |||
return LandType.MESA; | |||
case 0x056621: | |||
case 0x0b6659: | |||
case 0x537b09: | |||
case 0x2c4205: | |||
case 0x628b17: | |||
case 0x507050: | |||
case 0x2d8e49: | |||
case 0x8ab33f: | |||
case 0x687942: | |||
return LandType.FOREST; | |||
case 0x596651: | |||
case 0x454f3e: | |||
case 0x338e81: | |||
// return LandType.MUD; | |||
// case 0xfade55: | |||
// return LandType.BEACH; | |||
case 0xffffff: | |||
case 0xa0a0a0: | |||
case 0xfaf0c0: | |||
case 0x597d72: | |||
case 0x202070: | |||
case 0x202038: | |||
case 0x404090: | |||
case 0x163933: | |||
return LandType.ICE; | |||
case 0xfade55: | |||
case 0x8db360: | |||
case 0x07f9b2: | |||
case 0xbdb25f: | |||
case 0xa79d64: | |||
case 0xb5db88: | |||
case 0x000070: | |||
case 0x0000ff: | |||
case 0x0000ac: | |||
case 0x000090: | |||
case 0x000050: | |||
case 0x000040: | |||
default: | |||
break; | |||
} | |||
return LandType.PLAINS; | |||
} | |||
const isMountainBiome = (biomePngData: Buffer, x: number, y: number, width: number) => { | |||
const dataIndex = ((y * width) + x) * 4; | |||
switch (rgb( | |||
(biomePngData[dataIndex]), | |||
(biomePngData[dataIndex + 1]), | |||
(biomePngData[dataIndex + 2]), | |||
)) { | |||
case 0x606060: | |||
case 0xa0a0a0: | |||
case 0x507050: | |||
case 0x338e81: | |||
case 0x597d72: | |||
return true | |||
default: | |||
break | |||
} | |||
return false | |||
} | |||
const isHillBiome = (biomePngData: Buffer, x: number, y: number, width: number) => { | |||
const dataIndex = ((y * width) + x) * 4; | |||
switch (rgb( | |||
(biomePngData[dataIndex]), | |||
(biomePngData[dataIndex + 1]), | |||
(biomePngData[dataIndex + 2]), | |||
)) { | |||
case 0xd25f12: | |||
case 0x163933: | |||
case 0x2c4205: | |||
case 0x454f3e: | |||
case 0x687942: | |||
return true | |||
default: | |||
break | |||
} | |||
return false | |||
} | |||
const isWoodedBiome = (biomePngData: Buffer, x: number, y: number, width: number) => { | |||
const dataIndex = ((y * width) + x) * 4; | |||
switch (rgb( | |||
(biomePngData[dataIndex]), | |||
(biomePngData[dataIndex + 1]), | |||
(biomePngData[dataIndex + 2]), | |||
)) { | |||
case 0x056621: | |||
case 0x537b09: | |||
case 0x596651: | |||
case 0x2d8e49: | |||
return true; | |||
default: | |||
break | |||
} | |||
return false; | |||
} | |||
const index = (x: number, y: number, width: number) => { | |||
return ((y * width) + x) * 4; | |||
} | |||
const landThreshold = 0x80 | |||
const SHALLOW_WATER_THRESHOLD = 0xE0 | |||
const checkNeighboringLands = (waterMaskPngData: Buffer, x: number, y: number, width: number, height: number) => { | |||
const ny = y === 0 ? 0 : y - 1; | |||
const wx = (x + width - 1) % width; | |||
const ex = (x + 1) % width ; | |||
const sy = y === height - 1 ? height - 1 : y + 1; | |||
return { | |||
nw: waterMaskPngData[index(wx, ny, width)] < landThreshold, | |||
n: waterMaskPngData[index(x, ny, width)] < landThreshold, | |||
ne: waterMaskPngData[index(ex, ny, width)] < landThreshold, | |||
w: waterMaskPngData[index(wx, y, width)] < landThreshold, | |||
i: waterMaskPngData[index(x, y, width)], | |||
e: waterMaskPngData[index(ex, y, width)] < landThreshold, | |||
sw: waterMaskPngData[index(wx, sy, width)] < landThreshold, | |||
s: waterMaskPngData[index(x, sy, width)] < landThreshold, | |||
se: waterMaskPngData[index(ex, sy, width)] < landThreshold, | |||
}; | |||
} | |||
const determineBackgroundTileType = (landFlags: ReturnType<typeof checkNeighboringLands>) => { | |||
const islandThreshold = 0xE0; | |||
if (landFlags.i < landThreshold) { | |||
return 1; // 1 | |||
} | |||
if ( | |||
landFlags.i < islandThreshold | |||
&& !( | |||
landFlags.nw | |||
|| landFlags.n | |||
|| landFlags.ne | |||
|| landFlags.w | |||
|| landFlags.e | |||
|| landFlags.sw | |||
|| landFlags.s | |||
|| landFlags.se | |||
) | |||
) { | |||
return 29; // 29 | |||
} | |||
let cursor = 0; | |||
if (landFlags.n) { | |||
cursor += 12; // 12 | |||
if (landFlags.e) { | |||
cursor += 12; // 24 | |||
if (landFlags.s) { | |||
cursor += 14; // 38 | |||
if (landFlags.w) { | |||
cursor -= 10; // 28 | |||
} | |||
} else if (landFlags.w) { | |||
cursor += 16; // 40 | |||
} else if (landFlags.sw) { | |||
cursor += 12; // 36 | |||
} | |||
} else if (landFlags.w) { | |||
cursor += 15; // 27 | |||
if (landFlags.s) { | |||
cursor += 12; // 39 | |||
} else if (landFlags.se) { | |||
cursor += 10; // 37 | |||
} | |||
} else if (landFlags.s) { | |||
cursor += 31; // 43 | |||
} else if (landFlags.se) { | |||
cursor += 8; // 20 | |||
if (landFlags.sw) { | |||
cursor += 12; // 32 | |||
} | |||
} else if (landFlags.sw) { | |||
cursor += 11; // 23 | |||
} | |||
} else if (landFlags.s) { | |||
cursor += 13; // 13 | |||
if (landFlags.e) { | |||
cursor += 12; // 25 | |||
if (landFlags.w) { | |||
cursor += 16; // 41 | |||
} else if (landFlags.nw) { | |||
cursor += 10; // 35 | |||
} | |||
} else if (landFlags.w) { | |||
cursor += 13; // 26 | |||
if (landFlags.ne) { | |||
cursor += 8; // 34 | |||
} | |||
} else if (landFlags.ne) { | |||
cursor += 8; // 21 | |||
if (landFlags.nw) { | |||
cursor += 20; // 33 | |||
} | |||
} else if (landFlags.nw) { | |||
cursor += 9; // 22 | |||
} | |||
} else if (landFlags.w) { | |||
cursor += 14; // 14 | |||
if (landFlags.e) { | |||
cursor += 28; // 42 | |||
} else if (landFlags.se) { | |||
cursor += 4; // 18 | |||
if (landFlags.ne) { | |||
cursor += 13; // 31 | |||
} | |||
} else if (landFlags.ne) { | |||
cursor += 5; // 19 | |||
} | |||
} else if (landFlags.e) { | |||
cursor += 15; // 15 | |||
if (landFlags.nw) { | |||
cursor += 1 // 16 | |||
if (landFlags.sw) { | |||
cursor += 14; // 30 | |||
} | |||
} else if (landFlags.sw) { | |||
cursor += 2; // 17 | |||
} | |||
} else if (landFlags.ne) { | |||
cursor += 2; // 2 | |||
if (landFlags.nw) { | |||
cursor += 4; // 6 | |||
if (landFlags.sw) { | |||
cursor += 42; // 48 | |||
if (landFlags.se) { | |||
cursor -= 4; // 44 | |||
} | |||
} else if (landFlags.se) { | |||
cursor += 39; // 45 | |||
} | |||
} else if (landFlags.se) { | |||
cursor += 7; // 9 | |||
if (landFlags.sw) { | |||
cursor += 37; // 46 | |||
} | |||
} else if (landFlags.sw) { | |||
cursor += 8; // 10 | |||
} | |||
} else if (landFlags.nw) { | |||
cursor += 3; // 3 | |||
if (landFlags.sw) { | |||
cursor += 5; // 8 | |||
if (landFlags.se) { | |||
cursor += 39; // 47 | |||
} | |||
} else if (landFlags.se) { | |||
cursor += 8; // 11 | |||
} | |||
} else if (landFlags.sw) { | |||
cursor += 4; // 4 | |||
if (landFlags.se) { | |||
cursor += 3; // 7 | |||
} | |||
} else if (landFlags.se) { | |||
cursor += 5; // 5 | |||
} | |||
return cursor; | |||
} | |||
const generateWorldData = async (p: Projection, size: number, isWidthDimension: boolean) => { | |||
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`), | |||
}; | |||
})) | |||
const data = mapTypeBuffers.reduce<Record<MapType, PNG>>( | |||
(theData, mapTypeBuffer) => { | |||
return { | |||
...theData, | |||
[mapTypeBuffer.mapType]: PNG.sync.read(mapTypeBuffer.buffer), | |||
} | |||
}, | |||
{} as Record<MapType, PNG> | |||
); | |||
const dx = data[MapType.WATER_MASK].width; | |||
const dy = data[MapType.WATER_MASK].height; | |||
const worldData: WorldData = { | |||
version: '1.8.0.4', | |||
musicCategory: 0, | |||
width: dx, | |||
height: dy, | |||
spriteWaterLayer: [] as number[][], | |||
spriteBackgroundLayer: [] as number[][], | |||
spriteForegroundLayer: [] as number[][], | |||
connections: [] as number[][], | |||
tileTypes: [] as number[][], | |||
vehicleBoundaries: [] as number[][], | |||
stages: [] as number[][], | |||
warps: [] as number[][], | |||
vehicles: [] as number[][], | |||
initialItems: [] as number[][], | |||
} | |||
let i = 0; | |||
for (let y = 0; y < dy; y += 1) { | |||
worldData.spriteWaterLayer[y] = []; | |||
worldData.spriteBackgroundLayer[y] = []; | |||
worldData.spriteForegroundLayer[y] = []; | |||
for (let x = 0; x < dx; x += 1) { | |||
const isShallowWater = data[MapType.BATHYMETRY].data[i] >= SHALLOW_WATER_THRESHOLD; | |||
worldData.spriteWaterLayer[y][x] = isShallowWater ? WaterType.LIGHT : WaterType.DEFAULT; | |||
const landType = checkBiomeLandType(data[MapType.BIOME].data, x, y, dx); | |||
const tileType = determineBackgroundTileType(checkNeighboringLands(data[MapType.WATER_MASK].data, x, y, dx, dy)); | |||
worldData.spriteBackgroundLayer[y][x] = ((landType * 60) + tileType); | |||
worldData.spriteForegroundLayer[y][x] = 0; | |||
if (tileType === 1) { | |||
const isMountain = data[MapType.ELEVATION].data[i] >= 0x40 || isMountainBiome( | |||
data[MapType.BIOME].data, | |||
x, | |||
y, | |||
dx | |||
); | |||
if (isMountain) { | |||
switch (landType) { | |||
case LandType.DESERT: | |||
case LandType.MESA: | |||
worldData.spriteForegroundLayer[y][x] = 796; | |||
break; | |||
case LandType.ICE: | |||
worldData.spriteForegroundLayer[y][x] = 799; | |||
break; | |||
case LandType.FOREST: | |||
case LandType.PLAINS: | |||
default: | |||
worldData.spriteForegroundLayer[y][x] = 797; | |||
break; | |||
} | |||
} | |||
const isHill = isHillBiome(data[MapType.BIOME].data, x, y, dx); | |||
if (isHill) { | |||
if (landType === LandType.ICE) { | |||
worldData.spriteForegroundLayer[y][x] = 903 | |||
} else { | |||
worldData.spriteForegroundLayer[y][x] = 901 | |||
} | |||
} | |||
const isWooded = isWoodedBiome(data[MapType.BIOME].data, x, y, dx); | |||
if (isWooded) { | |||
if (landType === LandType.ICE) { | |||
worldData.spriteForegroundLayer[y][x] = 906 | |||
} else { | |||
worldData.spriteForegroundLayer[y][x] = 905 | |||
} | |||
} | |||
} | |||
i += 4; | |||
} | |||
} | |||
return worldData; | |||
} | |||
const generateHandler: NextApiHandler = async (req, res) => { | |||
const { | |||
projection = Projection.EQUIRECTANGULAR, | |||
width, | |||
height, | |||
} = req.query | |||
let isWidthDimension: boolean; | |||
let size: number; | |||
if (width && Number.isFinite(Number(width))) { | |||
size = Number(width); | |||
isWidthDimension = true; | |||
} else if (height && Number.isFinite(Number(height))) { | |||
size = Number(height); | |||
isWidthDimension = false; | |||
} else { | |||
throw new Error('Unspecified width or height'); | |||
} | |||
await FileSystemServiceImpl.ensureDirectory('public/generated'); | |||
await FileSystemServiceImpl.ensureDirectory('public/generated/base'); | |||
await FileSystemServiceImpl.ensureDirectory('public/generated/resized'); | |||
await FileSystemServiceImpl.ensureDirectory('public/worlds'); | |||
const baseDataImageUrls = await Promise.all( | |||
Object | |||
.values(MapType) | |||
.map(async (t: MapType) => generateProjectedBaseData(t, projection as Projection)) | |||
); | |||
await Promise.all( | |||
Object | |||
.values(MapType) | |||
.map(async (t: MapType) => resizeBaseData(t, projection as Projection, size, isWidthDimension)) | |||
); | |||
const worldData = await generateWorldData(projection as Projection, size, isWidthDimension); | |||
res.json({ | |||
baseDataImageUrls, | |||
worldData, | |||
}); | |||
} | |||
export default generateHandler; |
@@ -0,0 +1,471 @@ | |||
import {NextPage} from 'next'; | |||
import NextImage from 'next/image'; | |||
import styles from '../styles/pages/index.module.css'; | |||
import {GenerateMapForm} from '../components/GenerateMapForm'; | |||
import getFormValues from '@theoryofnekomata/formxtra'; | |||
import { | |||
ChangeEventHandler, | |||
FormEventHandler, | |||
MouseEventHandler, | |||
UIEventHandler, | |||
useEffect, | |||
useRef, | |||
useState, | |||
} from 'react'; | |||
import {MapType, WaterType, WorldData} from '../utils/types'; | |||
import WORLD_BACKGROUND from '../assets/gfx/world_background.png'; | |||
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<HTMLImageElement>((resolve, reject) => { | |||
const originalImage = new Image() | |||
originalImage.addEventListener('load', (e) => { | |||
const theOriginalImage = e.target as HTMLImageElement | |||
if (!processTransparency) { | |||
resolve(theOriginalImage) | |||
return | |||
} | |||
const tempCanvas = window.document.createElement('canvas') | |||
tempCanvas.width = theOriginalImage.width | |||
tempCanvas.height = theOriginalImage.height | |||
const context = tempCanvas.getContext('2d') | |||
if (!context) { | |||
reject() | |||
return | |||
} | |||
context.drawImage(e.target as HTMLImageElement, 0, 0) | |||
const oldImageData = context.getImageData(0, 0, theOriginalImage.width, theOriginalImage.height) | |||
const newImageData = context.createImageData(oldImageData) | |||
for (let y = 0; y < oldImageData.height; y += 1) { | |||
for (let x = 0; x < oldImageData.width; x += 1) { | |||
const i = ((y * oldImageData.width) + x) * 4; | |||
if (oldImageData.data[i] === 255 && oldImageData.data[i + 1] === 0 && oldImageData.data[i + 2] === 255) { | |||
newImageData.data[i] = 0 | |||
newImageData.data[i + 1] = 0 | |||
newImageData.data[i + 2] = 0 | |||
newImageData.data[i + 3] = 0 | |||
continue; | |||
} | |||
newImageData.data[i] = oldImageData.data[i] | |||
newImageData.data[i + 1] = oldImageData.data[i + 1] | |||
newImageData.data[i + 2] = oldImageData.data[i + 2] | |||
newImageData.data[i + 3] = oldImageData.data[i + 3] | |||
} | |||
} | |||
context.clearRect(0, 0, theOriginalImage.width, theOriginalImage.height) | |||
context.putImageData(newImageData, 0, 0) | |||
const modifiedImage = new Image() | |||
modifiedImage.addEventListener('load', (e2) => { | |||
resolve(e2.target as HTMLImageElement) | |||
}) | |||
modifiedImage.addEventListener('error', () => { | |||
reject() | |||
}) | |||
modifiedImage.src = tempCanvas.toDataURL() | |||
}) | |||
originalImage.addEventListener('error', () => { | |||
reject() | |||
}) | |||
originalImage.src = src | |||
}) | |||
} | |||
type Data = { | |||
worldData: WorldData, | |||
baseDataImageUrls: string[], | |||
} | |||
const drawWater = (context: CanvasRenderingContext2D, backgroundImage: HTMLImageElement, spriteWaterLayer: number[][], x: number, y: number) => { | |||
const dx = x * 16; | |||
const dy = y * 16; | |||
// TODO animate water | |||
switch (spriteWaterLayer[y][x]) { | |||
case WaterType.LIGHT: | |||
context.drawImage(backgroundImage, 20 * 16, 0, 16, 16, dx, dy, 16, 16); | |||
return; | |||
case WaterType.LAVA: | |||
context.drawImage(backgroundImage, 24 * 16, 0, 16, 16, dx, dy, 16, 16); | |||
return; | |||
default: | |||
break; | |||
} | |||
context.drawImage(backgroundImage, 0, 0, 16, 16, dx, dy, 16, 16); | |||
} | |||
const drawLand = (context: CanvasRenderingContext2D, backgroundImage: HTMLImageElement, spriteBackgroundLayer: number[][], x: number, y: number) => { | |||
const tileIndex = spriteBackgroundLayer[y][x] % 60 | |||
const tileVariant = Math.floor(spriteBackgroundLayer[y][x] / 60) | |||
const dx = x * 16; | |||
const dy = y * 16; | |||
let tx = 4 * tileVariant; | |||
let ty = 1; | |||
switch (tileIndex) { | |||
case 0: | |||
return; | |||
case 1: | |||
tx += 1; | |||
break; | |||
default: | |||
if (tileIndex < 30) { | |||
tx += Math.floor((tileIndex - 2) / 14); | |||
ty = (tileIndex - 2) % 14 + 2; | |||
} else if (tileIndex < 60) { | |||
tx += Math.floor(tileIndex / 15); | |||
ty = tileIndex % 15 + 1; | |||
} | |||
break; | |||
} | |||
context.drawImage(backgroundImage, tx * 16, ty * 16, 16, 16, dx, dy, 16, 16); | |||
} | |||
const drawObjects = (context: CanvasRenderingContext2D, foregroundImage: HTMLImageElement, spriteBackgroundLayer: number[][], x: number, y: number) => { | |||
const dx = x * 16; | |||
const dy = y * 16; | |||
let tx: number; | |||
let ty: number; | |||
if (spriteBackgroundLayer[y][x] < 900) { | |||
tx = (spriteBackgroundLayer[y][x] - 700) % 12 | |||
ty = Math.floor((spriteBackgroundLayer[y][x] - 700) / 12) | |||
} else { | |||
tx = 13 | |||
ty = spriteBackgroundLayer[y][x] - 900 | |||
// fixme animated sprites in second column | |||
} | |||
context.drawImage(foregroundImage, tx * 16, ty * 16, 16, 16, dx, dy, 16, 16); | |||
} | |||
const drawWorld = async (canvas: HTMLCanvasElement, data: Data) => { | |||
const context = canvas.getContext('2d') | |||
if (!context) { | |||
return | |||
} | |||
context.clearRect(0, 0, canvas.width, canvas.height) | |||
const backgroundImage = await loadImage(WORLD_BACKGROUND.src, true) | |||
const foregroundImage = await loadImage(WORLD_FOREGROUND.src, true) | |||
for (let y = 0; y < data.worldData.height; y += 1) { | |||
for (let x = 0; x < data.worldData.width; x += 1) { | |||
drawWater(context, backgroundImage, data.worldData.spriteWaterLayer, x, y); | |||
drawLand(context, backgroundImage, data.worldData.spriteBackgroundLayer, x, y); | |||
drawObjects(context, foregroundImage, data.worldData.spriteForegroundLayer, x, y); | |||
} | |||
} | |||
} | |||
const createSuperMarioWarWorldFile = (worldData: WorldData) => { | |||
return `#Version | |||
${worldData.version} | |||
#Music Category | |||
${worldData.musicCategory} | |||
#Width | |||
${worldData.width} | |||
#Height | |||
${worldData.height} | |||
#Sprite Water Layer | |||
${worldData.spriteWaterLayer.map(s => s.join(',')).join('\n')} | |||
#Sprite Background Layer | |||
${worldData.spriteBackgroundLayer.map(s => s.join(',')).join('\n')} | |||
#Sprite Foreground Layer | |||
${worldData.spriteForegroundLayer.map(s => s.join(',')).join('\n')} | |||
#Connections | |||
${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')} | |||
#Tile Types (Stages, Doors, Start Tiles) | |||
${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')} | |||
#Vehicle Boundaries | |||
${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')} | |||
#Stages | |||
#Stage Type 0,Map,Mode,Goal,Points,Bonus List(Max 10),Name,End World, then mode settings (see sample tour file for details) | |||
#Stage Type 1,Bonus House Name,Sequential/Random Order,Display Text,Powerup List(Max 5) | |||
0 | |||
#Warps | |||
#location 1 x, y, location 2 x, y | |||
0 | |||
#Vehicles | |||
#Sprite,Stage Type, Start Column, Start Row, Min Moves, Max Moves, Sprite Paces, Sprite Direction, Boundary | |||
0 | |||
#Initial Items | |||
0 | |||
` | |||
} | |||
const IndexPage: NextPage = () => { | |||
const [data, setData] = useState<Data>() | |||
const [previewMapOpacity, setPreviewMapOpacity] = useState<Record<MapType, number>>(() => { | |||
return IMAGE_URLS_INDEX.reduce( | |||
(theValue, mapType) => { | |||
return { | |||
...theValue, | |||
[mapType]: 1, | |||
} | |||
}, | |||
{} as Record<MapType, number> | |||
); | |||
}) | |||
const canvasRef = useRef<HTMLCanvasElement>(null) | |||
const baseMapScrollRef = useRef<HTMLDivElement>(null) | |||
const worldScrollRef = useRef<HTMLDivElement>(null) | |||
const scrollRef = useRef(false) | |||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => { | |||
e.preventDefault() | |||
const form = e.target as HTMLFormElement | |||
const values = getFormValues(form) | |||
const url = new URL('/api/generate', 'http://localhost:3000'); | |||
const search = new URLSearchParams(values) | |||
url.search = search.toString() | |||
const response = await fetch(url.toString(), { | |||
method: 'GET', | |||
headers: { | |||
'Accept': 'application/json', | |||
}, | |||
}) | |||
const responseData = await response.json() | |||
if (response.ok) { | |||
setData(responseData) | |||
} | |||
} | |||
const saveScreenshot: MouseEventHandler = async (e) => { | |||
e.preventDefault() | |||
if (!canvasRef.current) { | |||
return; | |||
} | |||
canvasRef.current.toBlob((blob) => { | |||
const url = URL.createObjectURL(blob as Blob) | |||
const a = window.document.createElement('a') | |||
a.href = url | |||
a.download = 'world.png'; | |||
a.click() | |||
}, 'image/png'); | |||
} | |||
const saveWorld: MouseEventHandler = async (e) => { | |||
e.preventDefault() | |||
if (!data) { | |||
return; | |||
} | |||
const blob = new Blob([ | |||
createSuperMarioWarWorldFile(data.worldData) | |||
], { | |||
type: 'text/plain', | |||
}) | |||
const url = URL.createObjectURL(blob) | |||
const a = window.document.createElement('a') | |||
a.href = url | |||
a.download = 'world.txt'; | |||
a.click() | |||
} | |||
const changePreviewMapOpacity: ChangeEventHandler<HTMLInputElement> = (e) => { | |||
const { currentTarget } = e | |||
const { value, name } = currentTarget | |||
setPreviewMapOpacity(oldMapOpacity => ({ | |||
...oldMapOpacity, | |||
[name as MapType]: value, | |||
})); | |||
} | |||
const handleBaseMapScroll: UIEventHandler<HTMLDivElement> = (e) => { | |||
if (!worldScrollRef.current) { | |||
return; | |||
} | |||
if (scrollRef.current) { | |||
scrollRef.current = false; | |||
return; | |||
} | |||
scrollRef.current = true; | |||
const target = e.currentTarget | |||
worldScrollRef.current.scrollTop = Math.floor(target.scrollTop / (target.scrollHeight - target.offsetHeight) * (worldScrollRef.current.scrollHeight - worldScrollRef.current.offsetHeight)); | |||
worldScrollRef.current.scrollLeft = Math.floor(target.scrollLeft / (target.scrollWidth - target.offsetWidth) * (worldScrollRef.current.scrollWidth - worldScrollRef.current.offsetWidth)); | |||
} | |||
const handleWorldScroll: UIEventHandler<HTMLDivElement> = (e) => { | |||
if (!baseMapScrollRef.current) { | |||
return; | |||
} | |||
if (scrollRef.current) { | |||
scrollRef.current = false; | |||
return; | |||
} | |||
scrollRef.current = true; | |||
const target = e.currentTarget | |||
baseMapScrollRef.current.scrollTop = Math.floor(target.scrollTop / (target.scrollHeight - target.offsetHeight) * (baseMapScrollRef.current.scrollHeight - baseMapScrollRef.current.offsetHeight)); | |||
baseMapScrollRef.current.scrollLeft = Math.floor(target.scrollLeft / (target.scrollWidth - target.offsetWidth) * (baseMapScrollRef.current.scrollWidth - baseMapScrollRef.current.offsetWidth)); | |||
} | |||
useEffect(() => { | |||
if (!data) { | |||
return | |||
} | |||
if (!canvasRef.current) { | |||
return | |||
} | |||
void drawWorld(canvasRef.current, data); | |||
}, [data, canvasRef]) | |||
return ( | |||
<div | |||
className={styles.base} | |||
> | |||
<div | |||
className={styles.ui} | |||
> | |||
<form | |||
className={styles.form} | |||
onSubmit={handleSubmit} | |||
> | |||
<GenerateMapForm /> | |||
</form> | |||
</div> | |||
<div | |||
className={styles.map} | |||
> | |||
{ | |||
data | |||
&& ( | |||
<> | |||
<div | |||
className={styles.mapScroll} | |||
onScroll={handleBaseMapScroll} | |||
ref={baseMapScrollRef} | |||
> | |||
{ | |||
IMAGE_STACKING.map((v: MapType) => ( | |||
<img | |||
key={v} | |||
src={data.baseDataImageUrls[IMAGE_URLS_INDEX.indexOf(v)]} | |||
alt={v} | |||
style={{ | |||
opacity: previewMapOpacity[v], | |||
}} | |||
className={styles.baseMapCanvas} | |||
/> | |||
)) | |||
} | |||
</div> | |||
<div | |||
className={styles.mapForm} | |||
> | |||
{ | |||
IMAGE_STACKING.map((v: MapType) => ( | |||
<div | |||
key={v} | |||
> | |||
<label> | |||
<span | |||
className={styles.fieldLabel} | |||
> | |||
{v} | |||
</span> | |||
<input | |||
type="range" | |||
min={0} | |||
max={1} | |||
step={0.01} | |||
defaultValue={1} | |||
name={v} | |||
onChange={changePreviewMapOpacity} | |||
/> | |||
</label> | |||
</div> | |||
)) | |||
} | |||
</div> | |||
</> | |||
) | |||
} | |||
</div> | |||
<div | |||
className={styles.map} | |||
> | |||
{ | |||
data | |||
&& ( | |||
<> | |||
<div | |||
className={styles.mapScroll} | |||
onScroll={handleWorldScroll} | |||
ref={worldScrollRef} | |||
> | |||
<canvas | |||
width={data.worldData.width * 16} | |||
height={data.worldData.height * 16} | |||
ref={canvasRef} | |||
className={styles.mapCanvas} | |||
/> | |||
</div> | |||
<div | |||
className={styles.mapForm} | |||
> | |||
<ActionButton | |||
onClick={saveWorld} | |||
> | |||
Save World | |||
</ActionButton> | |||
{' '} | |||
<ActionButton | |||
onClick={saveScreenshot} | |||
> | |||
Save Screenshot | |||
</ActionButton> | |||
</div> | |||
</> | |||
) | |||
} | |||
</div> | |||
</div> | |||
) | |||
} | |||
export default IndexPage; |
@@ -0,0 +1,29 @@ | |||
.base { | |||
display: flex; | |||
flex-direction: column; | |||
height: 100%; | |||
} | |||
.cities { | |||
flex: auto; | |||
margin: 1.5rem 0; | |||
} | |||
.fieldset { | |||
display: contents; | |||
} | |||
.fieldsetWrapper { | |||
margin: 1.5rem 0; | |||
} | |||
.formGroup { | |||
margin-top: 1rem; | |||
} | |||
.horizontalFormGroup { | |||
margin-top: 1rem; | |||
display: grid; | |||
grid-template-columns: 1fr 1fr; | |||
gap: 1rem; | |||
} |
@@ -0,0 +1,16 @@ | |||
html, | |||
body { | |||
padding: 0; | |||
margin: 0; | |||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, | |||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; | |||
} | |||
a { | |||
color: inherit; | |||
text-decoration: none; | |||
} | |||
* { | |||
box-sizing: border-box; | |||
} |
@@ -0,0 +1,66 @@ | |||
.ui { | |||
width: 360px; | |||
padding: 0 1.5rem; | |||
} | |||
.form { | |||
display: contents; | |||
} | |||
@media (min-width: 720px) { | |||
.base { | |||
display: flex; | |||
height: 100vh; | |||
} | |||
.map { | |||
width: 0; | |||
flex: auto; | |||
min-width: 0; | |||
position: relative; | |||
} | |||
} | |||
.mapScroll { | |||
width: 100%; | |||
height: 100%; | |||
overflow: auto; | |||
position: relative; | |||
} | |||
/*.mapScroll::-webkit-scrollbar {*/ | |||
/* display: none;*/ | |||
/*}*/ | |||
.mapForm { | |||
position: absolute; | |||
top: 1.5rem; | |||
right: 1.5rem; | |||
z-index: 10; | |||
padding: 1rem; | |||
background-color: #fff; | |||
opacity: 0.25; | |||
} | |||
.mapForm:hover { | |||
opacity: 1; | |||
} | |||
.fieldLabel { | |||
display: block; | |||
} | |||
.mapCanvas { | |||
display: block; | |||
} | |||
.baseMapCanvas { | |||
display: block; | |||
position: absolute; | |||
top: 0; | |||
left: 0; | |||
} | |||
.baseMapCanvas:first-child { | |||
position: static; | |||
} |
@@ -0,0 +1,35 @@ | |||
export enum Projection { | |||
EQUIRECTANGULAR = 'equirectangular', | |||
MERCATOR = 'mercator', | |||
} | |||
export enum MapType { | |||
BIOME = 'biomes', | |||
ELEVATION = 'elevation', | |||
WATER_MASK = 'water-mask', | |||
BATHYMETRY = 'bathymetry', | |||
REAL_COLOR = 'real-color', | |||
} | |||
export type WorldData = { | |||
version: string, | |||
musicCategory: number, | |||
width: number, | |||
height: number, | |||
spriteWaterLayer: number[][], | |||
spriteBackgroundLayer: number[][], | |||
spriteForegroundLayer: number[][], | |||
connections: number[][], | |||
tileTypes: number[][], | |||
vehicleBoundaries: number[][], | |||
stages: number[][], | |||
warps: number[][], | |||
vehicles: number[][], | |||
initialItems: number[][], | |||
}; | |||
export enum WaterType { | |||
DEFAULT = 4, | |||
LIGHT = 5, | |||
LAVA = 6 | |||
} |
@@ -0,0 +1,20 @@ | |||
{ | |||
"compilerOptions": { | |||
"target": "es5", | |||
"lib": ["dom", "dom.iterable", "esnext"], | |||
"allowJs": true, | |||
"skipLibCheck": true, | |||
"strict": true, | |||
"forceConsistentCasingInFileNames": true, | |||
"noEmit": true, | |||
"esModuleInterop": true, | |||
"module": "esnext", | |||
"moduleResolution": "node", | |||
"resolveJsonModule": true, | |||
"isolatedModules": true, | |||
"jsx": "preserve", | |||
"incremental": true | |||
}, | |||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], | |||
"exclude": ["node_modules"] | |||
} |
@@ -0,0 +1,6 @@ | |||
{ | |||
"extends": "./tsconfig.json", | |||
"compilerOptions": { | |||
"module": "CommonJS" | |||
} | |||
} |