Generate Super Mario War worlds from real-world data.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

480 regels
14 KiB

  1. import {NextPage} from 'next';
  2. import NextImage from 'next/image';
  3. import styles from '../styles/pages/index.module.css';
  4. import {GenerateMapForm} from '../components/GenerateMapForm';
  5. import getFormValues from '@theoryofnekomata/formxtra';
  6. import {
  7. ChangeEventHandler,
  8. FormEventHandler,
  9. MouseEventHandler,
  10. UIEventHandler,
  11. useEffect,
  12. useRef,
  13. useState,
  14. } from 'react';
  15. import {MapType, WaterType, WorldData} from '../utils/types';
  16. import WORLD_BACKGROUND from '../assets/gfx/world_background.png';
  17. import WORLD_FOREGROUND from '../assets/gfx/world_foreground.png';
  18. import {DropdownSelect} from '../components/DropdownSelect';
  19. import {ActionButton} from '../components/ActionButton';
  20. const IMAGE_URLS_INDEX = [
  21. MapType.BIOME,
  22. MapType.ELEVATION,
  23. MapType.WATER_MASK,
  24. MapType.BATHYMETRY,
  25. MapType.REAL_COLOR,
  26. ]
  27. const IMAGE_STACKING = [
  28. MapType.WATER_MASK,
  29. MapType.BATHYMETRY,
  30. MapType.ELEVATION,
  31. MapType.REAL_COLOR,
  32. MapType.BIOME,
  33. ]
  34. const loadImage = async (src: string, processTransparency = false) => {
  35. return new Promise<HTMLImageElement>((resolve, reject) => {
  36. const originalImage = new Image()
  37. originalImage.addEventListener('load', (e) => {
  38. const theOriginalImage = e.target as HTMLImageElement
  39. if (!processTransparency) {
  40. resolve(theOriginalImage)
  41. return
  42. }
  43. const tempCanvas = window.document.createElement('canvas')
  44. tempCanvas.width = theOriginalImage.width
  45. tempCanvas.height = theOriginalImage.height
  46. const context = tempCanvas.getContext('2d')
  47. if (!context) {
  48. reject()
  49. return
  50. }
  51. context.drawImage(e.target as HTMLImageElement, 0, 0)
  52. const oldImageData = context.getImageData(0, 0, theOriginalImage.width, theOriginalImage.height)
  53. const newImageData = context.createImageData(oldImageData)
  54. for (let y = 0; y < oldImageData.height; y += 1) {
  55. for (let x = 0; x < oldImageData.width; x += 1) {
  56. const i = ((y * oldImageData.width) + x) * 4;
  57. if (oldImageData.data[i] === 255 && oldImageData.data[i + 1] === 0 && oldImageData.data[i + 2] === 255) {
  58. newImageData.data[i] = 0
  59. newImageData.data[i + 1] = 0
  60. newImageData.data[i + 2] = 0
  61. newImageData.data[i + 3] = 0
  62. continue;
  63. }
  64. newImageData.data[i] = oldImageData.data[i]
  65. newImageData.data[i + 1] = oldImageData.data[i + 1]
  66. newImageData.data[i + 2] = oldImageData.data[i + 2]
  67. newImageData.data[i + 3] = oldImageData.data[i + 3]
  68. }
  69. }
  70. context.clearRect(0, 0, theOriginalImage.width, theOriginalImage.height)
  71. context.putImageData(newImageData, 0, 0)
  72. const modifiedImage = new Image()
  73. modifiedImage.addEventListener('load', (e2) => {
  74. resolve(e2.target as HTMLImageElement)
  75. })
  76. modifiedImage.addEventListener('error', () => {
  77. reject()
  78. })
  79. modifiedImage.src = tempCanvas.toDataURL()
  80. })
  81. originalImage.addEventListener('error', () => {
  82. reject()
  83. })
  84. originalImage.src = src
  85. })
  86. }
  87. type Data = {
  88. worldData: WorldData,
  89. baseDataImageUrls: string[],
  90. }
  91. const drawWater = (context: CanvasRenderingContext2D, backgroundImage: HTMLImageElement, spriteWaterLayer: number[][], x: number, y: number) => {
  92. const dx = x * 16;
  93. const dy = y * 16;
  94. // TODO animate water
  95. switch (spriteWaterLayer[y][x]) {
  96. case WaterType.LIGHT:
  97. context.drawImage(backgroundImage, 20 * 16, 0, 16, 16, dx, dy, 16, 16);
  98. return;
  99. case WaterType.LAVA:
  100. context.drawImage(backgroundImage, 24 * 16, 0, 16, 16, dx, dy, 16, 16);
  101. return;
  102. default:
  103. break;
  104. }
  105. context.drawImage(backgroundImage, 0, 0, 16, 16, dx, dy, 16, 16);
  106. }
  107. const drawLand = (context: CanvasRenderingContext2D, backgroundImage: HTMLImageElement, spriteBackgroundLayer: number[][], x: number, y: number) => {
  108. const tileIndex = spriteBackgroundLayer[y][x] % 60
  109. const tileVariant = Math.floor(spriteBackgroundLayer[y][x] / 60)
  110. const dx = x * 16;
  111. const dy = y * 16;
  112. let tx = 4 * tileVariant;
  113. let ty = 1;
  114. switch (tileIndex) {
  115. case 0:
  116. return;
  117. case 1:
  118. tx += 1;
  119. break;
  120. default:
  121. if (tileIndex < 30) {
  122. tx += Math.floor((tileIndex - 2) / 14);
  123. ty = (tileIndex - 2) % 14 + 2;
  124. } else if (tileIndex < 60) {
  125. tx += Math.floor(tileIndex / 15);
  126. ty = tileIndex % 15 + 1;
  127. }
  128. break;
  129. }
  130. context.drawImage(backgroundImage, tx * 16, ty * 16, 16, 16, dx, dy, 16, 16);
  131. }
  132. const drawObjects = (context: CanvasRenderingContext2D, foregroundImage: HTMLImageElement, spriteBackgroundLayer: number[][], x: number, y: number) => {
  133. const dx = x * 16;
  134. const dy = y * 16;
  135. let tx: number;
  136. let ty: number;
  137. if (spriteBackgroundLayer[y][x] < 900) {
  138. tx = (spriteBackgroundLayer[y][x] - 700) % 12
  139. ty = Math.floor((spriteBackgroundLayer[y][x] - 700) / 12)
  140. } else {
  141. tx = 13
  142. ty = spriteBackgroundLayer[y][x] - 900
  143. // fixme animated sprites in second column
  144. }
  145. context.drawImage(foregroundImage, tx * 16, ty * 16, 16, 16, dx, dy, 16, 16);
  146. }
  147. const drawWorld = async (canvas: HTMLCanvasElement, data: Data) => {
  148. const context = canvas.getContext('2d')
  149. if (!context) {
  150. return
  151. }
  152. context.clearRect(0, 0, canvas.width, canvas.height)
  153. const backgroundImage = await loadImage(WORLD_BACKGROUND.src, true)
  154. const foregroundImage = await loadImage(WORLD_FOREGROUND.src, true)
  155. for (let y = 0; y < data.worldData.height; y += 1) {
  156. for (let x = 0; x < data.worldData.width; x += 1) {
  157. drawWater(context, backgroundImage, data.worldData.spriteWaterLayer, x, y);
  158. drawLand(context, backgroundImage, data.worldData.spriteBackgroundLayer, x, y);
  159. drawObjects(context, foregroundImage, data.worldData.spriteForegroundLayer, x, y);
  160. }
  161. }
  162. }
  163. const createSuperMarioWarWorldFile = (worldData: WorldData) => {
  164. return `#Version
  165. ${worldData.version}
  166. #Music Category
  167. ${worldData.musicCategory}
  168. #Width
  169. ${worldData.width}
  170. #Height
  171. ${worldData.height}
  172. #Sprite Water Layer
  173. ${worldData.spriteWaterLayer.map(s => s.join(',')).join('\n')}
  174. #Sprite Background Layer
  175. ${worldData.spriteBackgroundLayer.map(s => s.join(',')).join('\n')}
  176. #Sprite Foreground Layer
  177. ${worldData.spriteForegroundLayer.map(s => s.join(',')).join('\n')}
  178. #Connections
  179. ${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')}
  180. #Tile Types (Stages, Doors, Start Tiles)
  181. ${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')}
  182. #Vehicle Boundaries
  183. ${new Array(worldData.height).fill(new Array(worldData.width).fill('0').join(',')).join('\n')}
  184. #Stages
  185. #Stage Type 0,Map,Mode,Goal,Points,Bonus List(Max 10),Name,End World, then mode settings (see sample tour file for details)
  186. #Stage Type 1,Bonus House Name,Sequential/Random Order,Display Text,Powerup List(Max 5)
  187. 0
  188. #Warps
  189. #location 1 x, y, location 2 x, y
  190. 0
  191. #Vehicles
  192. #Sprite,Stage Type, Start Column, Start Row, Min Moves, Max Moves, Sprite Paces, Sprite Direction, Boundary
  193. 0
  194. #Initial Items
  195. 0
  196. `
  197. }
  198. const IndexPage: NextPage = () => {
  199. const [data, setData] = useState<Data>()
  200. const [previewMapOpacity, setPreviewMapOpacity] = useState<Record<MapType, number>>(() => {
  201. return IMAGE_URLS_INDEX.reduce(
  202. (theValue, mapType) => {
  203. return {
  204. ...theValue,
  205. [mapType]: 1,
  206. }
  207. },
  208. {} as Record<MapType, number>
  209. );
  210. })
  211. const canvasRef = useRef<HTMLCanvasElement>(null)
  212. const baseMapScrollRef = useRef<HTMLDivElement>(null)
  213. const worldScrollRef = useRef<HTMLDivElement>(null)
  214. const scrollRef = useRef(false)
  215. const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
  216. e.preventDefault()
  217. const form = e.target as HTMLFormElement
  218. const values = getFormValues(form)
  219. const url = new URL('/api/generate', 'http://localhost:3000');
  220. const search = new URLSearchParams(values)
  221. url.search = search.toString()
  222. const response = await fetch(url.toString(), {
  223. method: 'GET',
  224. headers: {
  225. 'Accept': 'application/json',
  226. },
  227. })
  228. const responseData = await response.json()
  229. if (response.ok) {
  230. setData(responseData)
  231. }
  232. }
  233. const saveScreenshot: MouseEventHandler = async (e) => {
  234. e.preventDefault()
  235. if (!canvasRef.current) {
  236. return;
  237. }
  238. canvasRef.current.toBlob((blob) => {
  239. const url = URL.createObjectURL(blob as Blob)
  240. const a = window.document.createElement('a')
  241. a.href = url
  242. a.download = 'world.png';
  243. a.click()
  244. }, 'image/png');
  245. }
  246. const saveWorld: MouseEventHandler = async (e) => {
  247. e.preventDefault()
  248. if (!data) {
  249. return;
  250. }
  251. const blob = new Blob([
  252. createSuperMarioWarWorldFile(data.worldData)
  253. ], {
  254. type: 'text/plain',
  255. })
  256. const url = URL.createObjectURL(blob)
  257. const a = window.document.createElement('a')
  258. a.href = url
  259. a.download = 'world.txt';
  260. a.click()
  261. }
  262. const changePreviewMapOpacity: ChangeEventHandler<HTMLInputElement> = (e) => {
  263. const { currentTarget } = e
  264. const { value, name } = currentTarget
  265. setPreviewMapOpacity(oldMapOpacity => ({
  266. ...oldMapOpacity,
  267. [name as MapType]: value,
  268. }));
  269. }
  270. const handleBaseMapScroll: UIEventHandler<HTMLDivElement> = (e) => {
  271. if (!worldScrollRef.current) {
  272. return;
  273. }
  274. if (scrollRef.current) {
  275. scrollRef.current = false;
  276. return;
  277. }
  278. scrollRef.current = true;
  279. const target = e.currentTarget
  280. if (target.scrollHeight > target.clientHeight) {
  281. worldScrollRef.current.scrollTop = Math.floor(target.scrollTop / (target.scrollHeight - target.clientHeight) * (worldScrollRef.current.scrollHeight - worldScrollRef.current.clientHeight));
  282. }
  283. if (target.scrollWidth > target.clientWidth) {
  284. worldScrollRef.current.scrollLeft = Math.floor(target.scrollLeft / (target.scrollWidth - target.clientWidth) * (worldScrollRef.current.scrollWidth - worldScrollRef.current.clientWidth));
  285. }
  286. }
  287. const handleWorldScroll: UIEventHandler<HTMLDivElement> = (e) => {
  288. if (!baseMapScrollRef.current) {
  289. return;
  290. }
  291. if (scrollRef.current) {
  292. scrollRef.current = false;
  293. return;
  294. }
  295. scrollRef.current = true;
  296. const target = e.currentTarget
  297. if (target.scrollHeight > target.clientHeight) {
  298. baseMapScrollRef.current.scrollTop = Math.floor(target.scrollTop / (target.scrollHeight - target.clientHeight) * (baseMapScrollRef.current.scrollHeight - baseMapScrollRef.current.clientHeight));
  299. }
  300. if (target.scrollWidth > target.clientWidth) {
  301. baseMapScrollRef.current.scrollLeft = Math.floor(target.scrollLeft / (target.scrollWidth - target.clientWidth) * (baseMapScrollRef.current.scrollWidth - baseMapScrollRef.current.clientWidth));
  302. }
  303. }
  304. useEffect(() => {
  305. if (!data) {
  306. return
  307. }
  308. if (!canvasRef.current) {
  309. return
  310. }
  311. void drawWorld(canvasRef.current, data);
  312. }, [data, canvasRef])
  313. return (
  314. <div
  315. className={styles.base}
  316. >
  317. <div
  318. className={styles.ui}
  319. >
  320. <form
  321. className={styles.form}
  322. onSubmit={handleSubmit}
  323. >
  324. <GenerateMapForm />
  325. </form>
  326. </div>
  327. <div
  328. className={styles.map}
  329. >
  330. {
  331. data
  332. && (
  333. <>
  334. <div
  335. className={styles.mapScroll}
  336. onScroll={handleBaseMapScroll}
  337. ref={baseMapScrollRef}
  338. >
  339. {
  340. IMAGE_STACKING.map((v: MapType) => (
  341. <img
  342. key={v}
  343. src={data.baseDataImageUrls[IMAGE_URLS_INDEX.indexOf(v)]}
  344. alt={v}
  345. style={{
  346. opacity: previewMapOpacity[v],
  347. }}
  348. className={styles.baseMapCanvas}
  349. />
  350. ))
  351. }
  352. </div>
  353. <div
  354. className={styles.mapForm}
  355. >
  356. {
  357. IMAGE_STACKING.map((v: MapType) => (
  358. <div
  359. key={v}
  360. >
  361. <label>
  362. <span
  363. className={styles.fieldLabel}
  364. >
  365. {v}
  366. </span>
  367. <input
  368. type="range"
  369. min={0}
  370. max={1}
  371. step={0.01}
  372. defaultValue={1}
  373. name={v}
  374. onChange={changePreviewMapOpacity}
  375. />
  376. </label>
  377. </div>
  378. ))
  379. }
  380. </div>
  381. </>
  382. )
  383. }
  384. </div>
  385. <div
  386. className={styles.map}
  387. >
  388. {
  389. data
  390. && (
  391. <>
  392. <div
  393. className={styles.mapScroll}
  394. onScroll={handleWorldScroll}
  395. ref={worldScrollRef}
  396. >
  397. <canvas
  398. width={data.worldData.width * 16}
  399. height={data.worldData.height * 16}
  400. ref={canvasRef}
  401. className={styles.mapCanvas}
  402. />
  403. </div>
  404. <div
  405. className={styles.mapForm}
  406. >
  407. <ActionButton
  408. onClick={saveWorld}
  409. >
  410. Save World
  411. </ActionButton>
  412. {' '}
  413. <ActionButton
  414. onClick={saveScreenshot}
  415. >
  416. Save Screenshot
  417. </ActionButton>
  418. </div>
  419. </>
  420. )
  421. }
  422. </div>
  423. </div>
  424. )
  425. }
  426. export default IndexPage;