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 (
+
+ );
+};
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 (
+
+ );
+};
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"]
+}