From 93b4f36d3f54fbd30da16dd98cc626bd2481f821 Mon Sep 17 00:00:00 2001 From: Gambitnl <147505131+Gambitnl@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:37:10 +0200 Subject: [PATCH] chore: configure vercel for web subdir --- vercel.json | 8 ++ web/.gitignore | 2 + web/index.html | 20 +++ web/package-lock.json | 114 +++++++++++++++ web/package.json | 20 +++ web/src/App.tsx | 99 +++++++++++++ web/src/components/SubmapPane.tsx | 22 +++ web/src/components/VillagePane.tsx | 48 +++++++ web/src/config/wfcRulesets/village.ts | 39 +++++ web/src/hooks/useSubmapProceduralData.ts | 36 +++++ web/src/index.tsx | 6 + web/src/services/villageGenerationService.ts | 143 +++++++++++++++++++ web/src/types/simple-wfc.d.ts | 1 + web/tsconfig.json | 15 ++ 14 files changed, 573 insertions(+) create mode 100644 vercel.json create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/components/SubmapPane.tsx create mode 100644 web/src/components/VillagePane.tsx create mode 100644 web/src/config/wfcRulesets/village.ts create mode 100644 web/src/hooks/useSubmapProceduralData.ts create mode 100644 web/src/index.tsx create mode 100644 web/src/services/villageGenerationService.ts create mode 100644 web/src/types/simple-wfc.d.ts create mode 100644 web/tsconfig.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..48320ec --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "builds": [ + { "src": "web/package.json", "use": "@vercel/static-build", "config": { "distDir": "web" } } + ], + "routes": [ + { "src": "/(.*)", "dest": "/web/$1" } + ] +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ea51a8d --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + Village Generator + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..70b9196 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "village-generator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "village-generator", + "version": "0.1.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "typescript": "^5.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.9.tgz", + "integrity": "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a7c47bd --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "village-generator", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "npm run build -- --watch", + "build": "tsc", + "test": "echo 'No tests specified'" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^19.1.9", + "@types/react-dom": "^19.1.7", + "typescript": "^5.4.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..3a15c7d --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,99 @@ +import React, { useState } from 'react'; +import { VillagePane } from './components/VillagePane'; +import { + generateWfcGrid, + transformGridToLayout, + VillageLayout, + VillageOptions, +} from './services/villageGenerationService'; + +const defaultOptions: VillageOptions = { + type: 'farming', + size: 'small', + includeFarmland: true, + includeMarket: true, + includeWalls: true, + includeWells: true, +}; + +export const App: React.FC = () => { + const [options, setOptions] = useState(defaultOptions); + const [layout, setLayout] = useState(); + + const handleCheckbox = (key: keyof VillageOptions) => ( + e: React.ChangeEvent + ) => { + setOptions((prev) => ({ ...prev, [key]: e.target.checked })); + }; + + const handleSelect = ( + key: 'type' | 'size' + ) => (e: React.ChangeEvent) => { + setOptions((prev) => ({ ...prev, [key]: e.target.value as any })); + }; + + const handleGenerate = async () => { + const seed = Date.now().toString(); + const grid = await generateWfcGrid(seed, options); + const l = transformGridToLayout(grid, options); + setLayout(l); + }; + + return ( +
+
+ + + + + + +
+ +
+ {layout && } +
+
+ ); +}; diff --git a/web/src/components/SubmapPane.tsx b/web/src/components/SubmapPane.tsx new file mode 100644 index 0000000..9c3847e --- /dev/null +++ b/web/src/components/SubmapPane.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useSubmapProceduralData } from '../hooks/useSubmapProceduralData'; +import { VillagePane } from './VillagePane'; +import { VillageOptions } from '../services/villageGenerationService'; + +interface Props { + currentWorldBiomeId: string; +} + +export const SubmapPane: React.FC = ({ currentWorldBiomeId }) => { + const options: VillageOptions = { type: 'farming', size: 'small' }; + const { villageLayout } = useSubmapProceduralData(currentWorldBiomeId, options); + + if (villageLayout) { + const handleEnterBuilding = (id: string, type: string) => { + console.log('ENTER_BUILDING', id, type); + }; + return ; + } + + return
Grid-based map not implemented.
; +}; diff --git a/web/src/components/VillagePane.tsx b/web/src/components/VillagePane.tsx new file mode 100644 index 0000000..fbc8247 --- /dev/null +++ b/web/src/components/VillagePane.tsx @@ -0,0 +1,48 @@ +import React, { FC } from 'react'; +import { VillageLayout } from '../services/villageGenerationService'; + +interface Props { + layout: VillageLayout; + onEnterBuilding?: (id: string, type: string) => void; +} + +export const VillagePane: FC = ({ layout, onEnterBuilding }) => { + const fillForType: Record = { + house: '#cfa', + farmland: '#deb887', + market: '#f5a', + well: '#ccc', + }; + return ( + + {layout.roads.map((road) => ( + `${p.x},${p.y}`).join(' ')} + stroke="sienna" + fill="none" + strokeWidth={0.2} + /> + ))} + {layout.buildings.map((b) => ( + `${p.x},${p.y}`).join(' ')} + fill={fillForType[b.type] || '#cfa'} + stroke="#333" + onClick={() => onEnterBuilding?.(b.id, b.type)} + style={{ cursor: 'pointer' }} + /> + ))} + {layout.walls.map((w) => ( + `${p.x},${p.y}`).join(' ')} + stroke="black" + fill="none" + strokeWidth={0.5} + /> + ))} + + ); +}; diff --git a/web/src/config/wfcRulesets/village.ts b/web/src/config/wfcRulesets/village.ts new file mode 100644 index 0000000..324b5f8 --- /dev/null +++ b/web/src/config/wfcRulesets/village.ts @@ -0,0 +1,39 @@ +export interface WfcTile { + id: string; +} + +export const villageTiles: WfcTile[] = [ + { id: 'grass' }, + { id: 'dirt' }, + { id: 'road_center' }, + { id: 'road_edge' }, + { id: 'building_wall_n' }, + { id: 'building_wall_s' }, + { id: 'building_door' }, + { id: 'building_roof_edge' }, + { id: 'building_roof_center' }, + { id: 'town_wall' }, + { id: 'gate' }, + { id: 'tower_base' }, + { id: 'farmland' }, + { id: 'market_stall' }, + { id: 'well' } +]; + +export const adjacencyRules: Record = { + grass: ['grass', 'dirt', 'road_edge', 'farmland'], + dirt: ['grass', 'dirt', 'road_edge'], + road_center: ['road_center', 'road_edge', 'gate'], + road_edge: ['road_center', 'road_edge', 'building_door', 'grass', 'dirt'], + building_wall_n: ['building_roof_edge', 'building_wall_n', 'building_door'], + building_wall_s: ['building_roof_edge', 'building_wall_s', 'building_door'], + building_door: ['road_edge', 'road_center'], + building_roof_edge: ['building_roof_center', 'building_wall_n', 'building_wall_s'], + building_roof_center: ['building_roof_center', 'building_roof_edge'], + town_wall: ['town_wall', 'gate', 'tower_base'], + gate: ['road_center', 'town_wall'], + tower_base: ['town_wall'], + farmland: ['farmland', 'grass', 'road_edge'], + market_stall: ['road_edge', 'road_center'], + well: ['road_center', 'road_edge'] +}; diff --git a/web/src/hooks/useSubmapProceduralData.ts b/web/src/hooks/useSubmapProceduralData.ts new file mode 100644 index 0000000..74a5ddd --- /dev/null +++ b/web/src/hooks/useSubmapProceduralData.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +import { + generateWfcGrid, + transformGridToLayout, + VillageLayout, + VillageOptions +} from '../services/villageGenerationService'; + +function simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash).toString(); +} + +export function useSubmapProceduralData( + currentWorldBiomeId: string, + options: VillageOptions +) { + const [villageLayout, setVillageLayout] = useState(); + + useEffect(() => { + if (currentWorldBiomeId === 'village') { + const seed = simpleHash(JSON.stringify(options)); + generateWfcGrid(seed, options).then((grid) => { + setVillageLayout(transformGridToLayout(grid, options)); + }); + } else { + setVillageLayout(undefined); + } + }, [currentWorldBiomeId, options]); + + return { villageLayout }; +} diff --git a/web/src/index.tsx b/web/src/index.tsx new file mode 100644 index 0000000..365649d --- /dev/null +++ b/web/src/index.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +const root = createRoot(document.getElementById('root')!); +root.render(); diff --git a/web/src/services/villageGenerationService.ts b/web/src/services/villageGenerationService.ts new file mode 100644 index 0000000..dbb268b --- /dev/null +++ b/web/src/services/villageGenerationService.ts @@ -0,0 +1,143 @@ +import WFC from 'simple-wfc'; +import { adjacencyRules, villageTiles, WfcTile } from '../config/wfcRulesets/village'; + +export interface VillageOptions { + type: 'farming' | 'fishing' | 'fortified'; + size: 'small' | 'medium'; + includeFarmland?: boolean; + includeMarket?: boolean; + includeWalls?: boolean; + includeWells?: boolean; +} + +export interface Point { + x: number; + y: number; +} + +export interface Building { + id: string; + type: string; + polygon: Point[]; + entryPoint: Point; +} + +export interface Road { + id: string; + pathPoints: Point[]; +} + +export interface Wall { + id: string; + pathPoints: Point[]; +} + +export interface VillageLayout { + buildings: Building[]; + roads: Road[]; + walls: Wall[]; +} + +export type WfcGrid = WfcTile[][]; + +export async function generateWfcGrid(seed: string, options: VillageOptions): Promise { + const size = options.size === 'small' ? 20 : 32; + const wfc = new WFC({ tiles: villageTiles, neighbors: adjacencyRules, seed }); + const result = await wfc.generate(size, size); + return result as WfcGrid; +} + +export function transformGridToLayout( + grid: WfcGrid, + options: VillageOptions +): VillageLayout { + // High level placeholder implementation. + // In a real implementation this would scan contiguous regions and + // construct buildings, roads and walls. + const layout: VillageLayout = { buildings: [], roads: [], walls: [] }; + + const allowFarmland = options.includeFarmland !== false; + const allowMarket = options.includeMarket !== false; + const allowWalls = options.includeWalls !== false; + const allowWells = options.includeWells !== false; + + // Example of identifying a single building from roof tiles + grid.forEach((row, y) => { + row.forEach((tile, x) => { + if (tile.id.startsWith('building_roof')) { + layout.buildings.push({ + id: `bldg_${x}_${y}`, + type: 'house', + polygon: [ + { x, y }, + { x: x + 1, y }, + { x: x + 1, y: y + 1 }, + { x, y: y + 1 } + ], + entryPoint: { x, y: y + 1 } + }); + } + if (tile.id.startsWith('road')) { + layout.roads.push({ + id: `road_${x}_${y}`, + pathPoints: [{ x, y }, { x: x + 1, y }] + }); + } + if (tile.id === 'farmland' && allowFarmland) { + layout.buildings.push({ + id: `farm_${x}_${y}`, + type: 'farmland', + polygon: [ + { x, y }, + { x: x + 1, y }, + { x: x + 1, y: y + 1 }, + { x, y: y + 1 } + ], + entryPoint: { x, y } + }); + } + if (tile.id === 'market_stall' && allowMarket) { + layout.buildings.push({ + id: `market_${x}_${y}`, + type: 'market', + polygon: [ + { x, y }, + { x: x + 1, y }, + { x: x + 1, y: y + 1 }, + { x, y: y + 1 } + ], + entryPoint: { x, y } + }); + } + if (tile.id === 'well' && allowWells) { + layout.buildings.push({ + id: `well_${x}_${y}`, + type: 'well', + polygon: [ + { x, y }, + { x: x + 1, y }, + { x: x + 1, y: y + 1 }, + { x, y: y + 1 } + ], + entryPoint: { x, y } + }); + } + if (tile.id.startsWith('town_wall') && allowWalls) { + layout.walls.push({ + id: `wall_${x}_${y}`, + pathPoints: [{ x, y }, { x: x + 1, y }] + }); + } + }); + }); + + if (!allowWalls) layout.walls = []; + if (!allowFarmland) + layout.buildings = layout.buildings.filter((b) => b.type !== 'farmland'); + if (!allowMarket) + layout.buildings = layout.buildings.filter((b) => b.type !== 'market'); + if (!allowWells) + layout.buildings = layout.buildings.filter((b) => b.type !== 'well'); + + return layout; +} diff --git a/web/src/types/simple-wfc.d.ts b/web/src/types/simple-wfc.d.ts new file mode 100644 index 0000000..5569237 --- /dev/null +++ b/web/src/types/simple-wfc.d.ts @@ -0,0 +1 @@ +declare module 'simple-wfc'; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e185b4e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "ESNext", + "jsx": "react-jsx", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "baseUrl": "./src", + "outDir": "./dist" + }, + "include": ["src"] +}