Przeglądaj źródła

Initial commit

Add files from create-next-app
master
TheoryOfNekomata 2 lat temu
commit
f0261a1aa8
33 zmienionych plików z 4488 dodań i 0 usunięć
  1. +3
    -0
      .eslintrc.json
  2. +42
    -0
      .gitignore
  3. +37
    -0
      README.md
  4. +1
    -0
      global.d.ts
  5. +5
    -0
      next-env.d.ts
  6. +6
    -0
      next.config.js
  7. +35
    -0
      package.json
  8. BIN
      public/favicon.ico
  9. +4
    -0
      public/vercel.svg
  10. +28
    -0
      scripts/link-gfx.ts
  11. BIN
      src/assets/data/000/bathymetry.png
  12. BIN
      src/assets/data/000/biomes.png
  13. BIN
      src/assets/data/000/elevation.png
  14. BIN
      src/assets/data/000/real-color.png
  15. BIN
      src/assets/data/000/water-mask.png
  16. +1
    -0
      src/assets/gfx
  17. +22
    -0
      src/backend/services/FileSystem.service.ts
  18. +57
    -0
      src/backend/services/Projection.service.ts
  19. +28
    -0
      src/components/ActionButton/index.tsx
  20. +25
    -0
      src/components/Checkbox/index.tsx
  21. +71
    -0
      src/components/DropdownSelect/index.tsx
  22. +216
    -0
      src/components/GenerateMapForm/index.tsx
  23. +26
    -0
      src/components/NumericInput/index.tsx
  24. +8
    -0
      src/pages/_app.tsx
  25. +507
    -0
      src/pages/api/generate/index.ts
  26. +471
    -0
      src/pages/index.tsx
  27. +29
    -0
      src/styles/components/GenerateMapForm/index.module.css
  28. +16
    -0
      src/styles/globals.css
  29. +66
    -0
      src/styles/pages/index.module.css
  30. +35
    -0
      src/utils/types.ts
  31. +20
    -0
      tsconfig.json
  32. +6
    -0
      tsconfig.script.json
  33. +2723
    -0
      yarn.lock

+ 3
- 0
.eslintrc.json Wyświetl plik

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

+ 42
- 0
.gitignore Wyświetl plik

@@ -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/

+ 37
- 0
README.md Wyświetl plik

@@ -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.

+ 1
- 0
global.d.ts Wyświetl plik

@@ -0,0 +1 @@
declare module 'd3-geo-polygon';

+ 5
- 0
next-env.d.ts Wyświetl plik

@@ -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.

+ 6
- 0
next.config.js Wyświetl plik

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}

module.exports = nextConfig

+ 35
- 0
package.json Wyświetl plik

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

BIN
public/favicon.ico Wyświetl plik

Przed Po

+ 4
- 0
public/vercel.svg Wyświetl plik

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

+ 28
- 0
scripts/link-gfx.ts Wyświetl plik

@@ -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()

BIN
src/assets/data/000/bathymetry.png Wyświetl plik

Przed Po
Szerokość: 4608  |  Wysokość: 2304  |  Rozmiar: 4.9 MiB

BIN
src/assets/data/000/biomes.png Wyświetl plik

Przed Po
Szerokość: 4608  |  Wysokość: 2304  |  Rozmiar: 1.4 MiB

BIN
src/assets/data/000/elevation.png Wyświetl plik

Przed Po
Szerokość: 4608  |  Wysokość: 2304  |  Rozmiar: 2.8 MiB

BIN
src/assets/data/000/real-color.png Wyświetl plik

Przed Po
Szerokość: 4608  |  Wysokość: 2304  |  Rozmiar: 7.3 MiB

BIN
src/assets/data/000/water-mask.png Wyświetl plik

Przed Po
Szerokość: 4608  |  Wysokość: 2304  |  Rozmiar: 1.8 MiB

+ 1
- 0
src/assets/gfx Wyświetl plik

@@ -0,0 +1 @@
D:/Apps/Games/Super Mario War/data/gfx/packs/Classic/world/preview

+ 22
- 0
src/backend/services/FileSystem.service.ts Wyświetl plik

@@ -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`);
}
}
}

+ 57
- 0
src/backend/services/Projection.service.ts Wyświetl plik

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


+ 28
- 0
src/components/ActionButton/index.tsx Wyświetl plik

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

+ 25
- 0
src/components/Checkbox/index.tsx Wyświetl plik

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

+ 71
- 0
src/components/DropdownSelect/index.tsx Wyświetl plik

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

+ 216
- 0
src/components/GenerateMapForm/index.tsx Wyświetl plik

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

+ 26
- 0
src/components/NumericInput/index.tsx Wyświetl plik

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

+ 8
- 0
src/pages/_app.tsx Wyświetl plik

@@ -0,0 +1,8 @@
import '../styles/globals.css'
import type { AppProps } from 'next/app'

const App = ({ Component, pageProps }: AppProps) => (
<Component {...pageProps} />
)

export default App

+ 507
- 0
src/pages/api/generate/index.ts Wyświetl plik

@@ -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;

+ 471
- 0
src/pages/index.tsx Wyświetl plik

@@ -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;

+ 29
- 0
src/styles/components/GenerateMapForm/index.module.css Wyświetl plik

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

+ 16
- 0
src/styles/globals.css Wyświetl plik

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

+ 66
- 0
src/styles/pages/index.module.css Wyświetl plik

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

+ 35
- 0
src/utils/types.ts Wyświetl plik

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

+ 20
- 0
tsconfig.json Wyświetl plik

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

+ 6
- 0
tsconfig.script.json Wyświetl plik

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS"
}
}

+ 2723
- 0
yarn.lock
Plik diff jest za duży
Wyświetl plik


Ładowanie…
Anuluj
Zapisz