@@ -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" | |||||
} | |||||
} |