Initial commit

This commit is contained in:
2025-11-27 18:37:31 +08:00
commit b403672264
44 changed files with 15109 additions and 0 deletions

24
voxel-tactics-horizon/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voxel-tactics-horizon</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5183
voxel-tactics-horizon/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
{
"name": "voxel-tactics-horizon",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.4.0",
"@react-three/postprocessing": "^3.0.4",
"@types/three": "^0.181.0",
"@types/uuid": "^10.0.0",
"clsx": "^2.1.1",
"postprocessing": "^6.38.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"simplex-noise": "^4.0.3",
"tailwind-merge": "^3.4.0",
"three": "^0.181.2",
"uuid": "^13.0.0",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.22",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,280 @@
import { useEffect, Suspense, useState, useMemo, useRef } from 'react';
import { Canvas } from '@react-three/fiber';
import { ACESFilmicToneMapping, PCFShadowMap } from 'three';
// Post-processing removed for now to debug and simplify
// import { EffectComposer, TiltShift2, Vignette } from '@react-three/postprocessing';
import { ChunkRenderer } from './features/Map/components/ChunkRenderer';
import { CameraRig } from './core/CameraRig';
import { MinecraftSky, getLightingConfig, type SkyPreset } from './core/MinecraftSky';
import { useMapStore } from './features/Map/store';
import { MAP_SIZES, TERRAIN_VERSION } from './features/Map/logic/terrain';
import { getAllSceneTypes, type SceneType, SCENE_CONFIGS } from './features/Map/logic/scenes';
// 临时注释:专注场景设计和地形生成算法优化
// import { UnitRenderer } from './features/Units/components/UnitRenderer';
// import { SelectionCursor } from './features/Map/components/Cursor';
// import { MoveOverlay } from './features/Map/components/MoveOverlay';
import { TerrainGenerationProgress } from './features/Map/components/TerrainGenerationProgress';
// import { useUnitStore } from './features/Units/store';
// import { useBattleStore } from './features/Battle/store';
function GameScene({ skyPreset }: { skyPreset: SkyPreset }) {
const generateMap = useMapStore((state) => state.generateMap);
const terrainVersion = useMapStore((state) => state.terrainVersion);
// 临时注释:战斗系统相关
// const addUnit = useUnitStore((state) => state.addUnit);
// const startBattle = useBattleStore((state) => state.startBattle);
// const moveActiveUnit = useBattleStore((state) => state.moveActiveUnit);
// const phase = useBattleStore((state) => state.phase);
const lighting = useMemo(() => getLightingConfig(skyPreset), [skyPreset]);
// 使用 ref 跟踪上一次的版本号
const prevVersionRef = useRef(TERRAIN_VERSION);
// 初始化地图 - 固定为沙漠场景方便调试
useEffect(() => {
generateMap('small', undefined, 'desert');
// 临时注释:角色生成和战斗初始化
// setTimeout(() => {
// const map = useMapStore.getState();
// addUnit({
// name: 'Ramza',
// job: 'Squire',
// team: 'player',
// position: { x: 5, y: map.getHeightAt(5,5) - 1, z: 5 },
// facing: 2,
// maxHp: 100,
// stats: { move: 4, jump: 2, speed: 10, attack: 8, defense: 6 }
// });
// addUnit({
// name: 'Goblin',
// job: 'Archer',
// team: 'enemy',
// position: { x: 8, y: map.getHeightAt(8,8) - 1, z: 8 },
// facing: 3,
// maxHp: 50,
// stats: { move: 3, jump: 3, speed: 11, attack: 9, defense: 4 }
// });
// startBattle();
// }, 500);
}, [generateMap]);
// 监听地形版本变化并自动重新生成
useEffect(() => {
if (prevVersionRef.current !== TERRAIN_VERSION && terrainVersion !== TERRAIN_VERSION) {
console.log(`[HMR] Terrain version changed: ${prevVersionRef.current} -> ${TERRAIN_VERSION}, regenerating map...`);
prevVersionRef.current = TERRAIN_VERSION;
generateMap('small');
}
}, [TERRAIN_VERSION, terrainVersion, generateMap]);
// 临时注释:体素点击和移动逻辑
// const onVoxelClick = (x: number, z: number) => {
// if (phase === 'PLAYER_TURN') {
// moveActiveUnit({ x, z });
// }
// };
return (
<>
{/* 天空盒 - 必须在最前面渲染 */}
<MinecraftSky preset={skyPreset} />
{/* 补充光照系统 */}
<ambientLight intensity={lighting.ambientIntensity} color={lighting.ambientColor} />
<hemisphereLight
args={['#87CEEB', '#6B8E23', 0.8]} // 从0.5增加到0.8
/>
<directionalLight
position={lighting.directionalPosition}
intensity={lighting.directionalIntensity}
color={lighting.directionalColor}
castShadow
shadow-mapSize={[1536, 1536]}
shadow-bias={-0.00005}
>
<orthographicCamera attach="shadow-camera" args={[-35, 35, 60, -20, 10, 220]} />
</directionalLight>
{/* 补充光源:从另一侧添加柔和的光照 */}
<directionalLight
position={[80, 60, -30]}
intensity={0.4}
color="#ffffff"
/>
{/* 游戏对象 */}
<ChunkRenderer onVoxelClick={undefined} />
{/* 临时注释:战斗和角色渲染 */}
{/* <MoveOverlay /> */}
{/* <UnitRenderer /> */}
{/* <SelectionCursor /> */}
{/* 相机控制 */}
<CameraRig />
</>
);
}
function UIOverlay({ skyPreset, setSkyPreset }: {
skyPreset: SkyPreset;
setSkyPreset: (preset: SkyPreset) => void;
}) {
const generateMap = useMapStore(s => s.generateMap);
const currentScene = useMapStore(s => s.currentScene);
// 临时注释战斗系统UI状态
// const phase = useBattleStore(s => s.phase);
// const activeUnitId = useBattleStore(s => s.activeUnitId);
// const endTurn = useBattleStore(s => s.endTurn);
// const activeUnit = useUnitStore(s => s.units.find(u => u.id === activeUnitId));
// 地图生成控制状态
const [mapSize, setMapSize] = useState<keyof typeof MAP_SIZES>('small');
const [selectedScene, setSelectedScene] = useState<SceneType>(currentScene);
const allScenes = useMemo(() => getAllSceneTypes(), []);
const handleGenerateMap = () => {
generateMap(mapSize, undefined, selectedScene);
};
return (
<div className="absolute top-4 left-4 p-4 bg-black/50 text-white rounded-lg backdrop-blur-sm pointer-events-none select-none w-96">
<div className="pointer-events-auto">
<h1 className="text-xl font-bold mb-2">Voxel Tactics: Horizon</h1>
{/* 地图生成控制 */}
<div className="mb-4 p-3 bg-gray-800 rounded shadow-lg border border-gray-700">
<label className="text-xs text-gray-400 block mb-1">Map Generator</label>
{/* 地图大小选择 */}
<div className="mb-2">
<label className="text-xs text-gray-300 block mb-1">Map Size</label>
<select
value={mapSize}
onChange={(e) => setMapSize(e.target.value as keyof typeof MAP_SIZES)}
className="w-full bg-gray-700 text-white px-2 py-1 rounded text-sm"
>
<option value="small">Small (16x16)</option>
<option value="medium">Medium (24x24)</option>
<option value="large">Large (32x32)</option>
</select>
</div>
{/* 场景选择 */}
<div className="mb-2">
<label className="text-xs text-gray-300 block mb-1">Scene Type</label>
<select
value={selectedScene}
onChange={(e) => setSelectedScene(e.target.value as SceneType)}
className="w-full bg-gray-700 text-white px-2 py-1 rounded text-sm"
>
{allScenes.map(scene => (
<option key={scene} value={scene}>
{SCENE_CONFIGS[scene].displayName}
</option>
))}
</select>
</div>
{/* 生成按钮 */}
<button
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium transition"
onClick={handleGenerateMap}
>
Generate Map
</button>
</div>
{/* 天气选择器 */}
<div className="mb-4 p-2 bg-gray-800 rounded shadow-lg border border-gray-700">
<label className="text-xs text-gray-400 block mb-1">Sky / Weather</label>
<select
value={skyPreset}
onChange={(e) => setSkyPreset(e.target.value as SkyPreset)}
className="w-full bg-gray-700 text-white px-2 py-1 rounded text-sm"
>
<option value="sunrise">Sunrise</option>
<option value="day">Day</option>
<option value="sunset">Sunset</option>
<option value="night">Night</option>
<option value="overcast">Overcast</option>
<option value="stormy">Stormy</option>
</select>
</div>
{/* 临时注释战斗UI面板 */}
{/* <div className="mb-4 p-2 bg-gray-800 rounded shadow-lg border border-gray-700">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-yellow-400">PHASE: {phase}</span>
<span className="text-xs text-gray-400">Turn Order</span>
</div>
{activeUnit ? (
<div className="mt-2">
<div className="text-lg font-bold">{activeUnit.name} <span className="text-sm font-normal text-gray-300">({activeUnit.job})</span></div>
<div className="w-full bg-gray-700 h-2 mt-1 rounded">
<div className="bg-green-500 h-full rounded" style={{ width: `${(activeUnit.currentHp/activeUnit.maxHp)*100}%` }}></div>
</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs text-gray-300">
<div>MOV: {activeUnit.stats.move}</div>
<div>JMP: {activeUnit.stats.jump}</div>
<div>SPD: {activeUnit.stats.speed}</div>
<div>ATK: {activeUnit.stats.attack}</div>
</div>
{phase === 'PLAYER_TURN' && (
<div className="mt-3 grid grid-cols-2 gap-2">
<button className="py-1 bg-blue-600 rounded hover:bg-blue-500">Action (TBD)</button>
<button className="py-1 bg-gray-600 rounded hover:bg-gray-500" onClick={endTurn}>Wait (End)</button>
</div>
)}
</div>
) : (
<div className="text-gray-500 text-sm">Calculating CT...</div>
)}
</div> */}
<div className="text-xs text-gray-400 mt-2">
<p>Controls:</p>
<p>WASD / Left Drag: Move Camera</p>
<p>Arrow Up/Down: Zoom</p>
<p>Arrow Left/Right: Adjust Pitch Angle</p>
<p>Space: Reset Camera</p>
<p>Q/E: Rotate 90°</p>
</div>
</div>
</div>
);
}
export default function App() {
const [skyPreset, setSkyPreset] = useState<SkyPreset>('day');
return (
<div className="w-full h-screen relative bg-gray-900">
<Canvas
shadows={{ enabled: true, type: PCFShadowMap }}
dpr={[1, 2]}
gl={{
antialias: true,
alpha: false,
toneMapping: ACESFilmicToneMapping,
toneMappingExposure: 1.1, // 从0.80增加到1.1,提高整体亮度
}}
camera={{
position: [0, 20, 20],
fov: 45,
near: 0.1,
far: 20000 // 足够大以包含天空盒
}}
>
<Suspense fallback={null}>
<GameScene skyPreset={skyPreset} />
</Suspense>
</Canvas>
<UIOverlay skyPreset={skyPreset} setSkyPreset={setSkyPreset} />
<TerrainGenerationProgress />
</div>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
interface LoadingProgressProps {
isLoading: boolean;
progress: number;
message?: string;
}
export const LoadingProgress: React.FC<LoadingProgressProps> = ({
isLoading,
progress,
message = 'Generating terrain...'
}) => {
const [displayProgress, setDisplayProgress] = useState(0);
useEffect(() => {
// 平滑进度条动画
const interval = setInterval(() => {
setDisplayProgress(prev => {
if (prev < progress) {
return Math.min(prev + 2, progress);
}
return prev;
});
}, 16); // ~60fps
return () => clearInterval(interval);
}, [progress]);
if (!isLoading && displayProgress >= 100) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="w-96 p-6 bg-gray-900/90 rounded-lg border border-gray-700 shadow-2xl">
<h3 className="text-xl font-bold text-white mb-4 text-center">
🌍 {message}
</h3>
<div className="relative h-4 bg-gray-800 rounded-full overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 via-cyan-500 to-green-500 transition-all duration-300 ease-out"
style={{ width: `${displayProgress}%` }}
>
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
</div>
<div className="mt-3 text-center text-sm text-gray-400">
{Math.round(displayProgress)}% Complete
</div>
<div className="mt-4 text-xs text-gray-500 text-center">
Generating voxels, placing decorations, optimizing...
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,215 @@
import { useEffect, useRef } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import { Vector3, MathUtils } from 'three';
import { useMapStore } from '../features/Map/store';
const clamp = (val: number, min: number, max: number) => Math.max(min, Math.min(max, val));
const lerpScalar = (current: number, target: number, delta: number, speed = 6) =>
MathUtils.lerp(current, target, Math.min(1, delta * speed));
export const CameraRig: React.FC = () => {
const { camera, gl } = useThree();
const mapSize = useMapStore((state) => state.size);
const baseAngle = Math.PI / 4;
const angleIndexRef = useRef(0);
// 俯仰角控制pitch angle
const pitchAngleRef = useRef(0.6); // 默认俯仰角(弧度)
const desiredPitchAngleRef = useRef(0.6);
const targetRef = useRef(new Vector3(mapSize / 2, 0, mapSize / 2));
const desiredTargetRef = useRef(targetRef.current.clone());
const zoomRef = useRef(22);
const desiredZoomRef = useRef(22);
const isDragging = useRef(false);
const lastMousePos = useRef({ x: 0, y: 0 });
const movementRef = useRef({
forward: false,
back: false,
left: false,
right: false,
});
useEffect(() => {
const canvas = gl.domElement;
// 确保 canvas 获得焦点
const ensureFocus = () => {
if (document.activeElement !== canvas) {
canvas.focus();
}
};
// 页面加载时立即获取焦点
canvas.setAttribute('tabindex', '0');
canvas.focus();
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
desiredZoomRef.current = clamp(
desiredZoomRef.current + e.deltaY * 0.01,
8,
45
);
};
const handleMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
ensureFocus(); // 点击时确保获得焦点
isDragging.current = true;
lastMousePos.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDragging.current = false;
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const dx = e.clientX - lastMousePos.current.x;
const dy = e.clientY - lastMousePos.current.y;
lastMousePos.current = { x: e.clientX, y: e.clientY };
const angle = baseAngle + angleIndexRef.current * (Math.PI / 2);
const right = new Vector3(1, 0, 0).applyAxisAngle(new Vector3(0, 1, 0), angle);
const forward = new Vector3(0, 0, -1).applyAxisAngle(new Vector3(0, 1, 0), angle);
const dragSpeed = 0.025;
// Drag canvas: move target opposite to mouse movement on screen
desiredTargetRef.current.addScaledVector(right, -dx * dragSpeed);
desiredTargetRef.current.addScaledVector(forward, dy * dragSpeed);
};
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key.toLowerCase()) {
case 'w':
movementRef.current.forward = true;
break;
case 's':
movementRef.current.back = true;
break;
case 'a':
movementRef.current.left = true;
break;
case 'd':
movementRef.current.right = true;
break;
case 'arrowup':
e.preventDefault();
desiredZoomRef.current = clamp(desiredZoomRef.current - 1, 8, 45);
break;
case 'arrowdown':
e.preventDefault();
desiredZoomRef.current = clamp(desiredZoomRef.current + 1, 8, 45);
break;
case 'arrowleft':
// 左箭头:降低俯仰角(镜头向下看)
e.preventDefault();
desiredPitchAngleRef.current = clamp(desiredPitchAngleRef.current - 0.15, 0.2, 1.2);
break;
case 'arrowright':
// 右箭头:增加俯仰角(镜头向上看)
e.preventDefault();
desiredPitchAngleRef.current = clamp(desiredPitchAngleRef.current + 0.15, 0.2, 1.2);
break;
case 'q':
angleIndexRef.current = (angleIndexRef.current + 1) % 4;
break;
case 'e':
angleIndexRef.current = (angleIndexRef.current + 3) % 4;
break;
case ' ':
desiredTargetRef.current.set(mapSize / 2, 0, mapSize / 2);
targetRef.current.copy(desiredTargetRef.current);
desiredZoomRef.current = 22;
zoomRef.current = 22;
angleIndexRef.current = 0;
desiredPitchAngleRef.current = 0.6; // 重置俯仰角
pitchAngleRef.current = 0.6;
break;
}
};
const handleKeyUp = (e: KeyboardEvent) => {
switch (e.key.toLowerCase()) {
case 'w':
movementRef.current.forward = false;
break;
case 's':
movementRef.current.back = false;
break;
case 'a':
movementRef.current.left = false;
break;
case 'd':
movementRef.current.right = false;
break;
}
};
canvas.addEventListener('wheel', handleWheel, { passive: false });
window.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
canvas.removeEventListener('wheel', handleWheel);
window.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [gl.domElement, mapSize]);
useFrame((_, delta) => {
// Keyboard translation
const moveSpeed = 8;
const angle = baseAngle + angleIndexRef.current * (Math.PI / 2);
const right = new Vector3(1, 0, 0).applyAxisAngle(new Vector3(0, 1, 0), angle);
const forward = new Vector3(0, 0, -1).applyAxisAngle(new Vector3(0, 1, 0), angle);
const moveDir = new Vector3();
if (movementRef.current.forward) moveDir.add(forward);
if (movementRef.current.back) moveDir.addScaledVector(forward, -1);
if (movementRef.current.left) moveDir.addScaledVector(right, -1);
if (movementRef.current.right) moveDir.add(right);
if (moveDir.lengthSq() > 0) {
moveDir.normalize();
desiredTargetRef.current.addScaledVector(moveDir, moveSpeed * delta);
}
const alpha = Math.min(1, delta * 6);
targetRef.current.lerp(desiredTargetRef.current, alpha);
zoomRef.current = lerpScalar(zoomRef.current, desiredZoomRef.current, delta, 4);
pitchAngleRef.current = lerpScalar(pitchAngleRef.current, desiredPitchAngleRef.current, delta, 4);
const zoom = zoomRef.current;
const pitchAngle = pitchAngleRef.current;
// 在浏览器标题中显示俯仰角(调试用)
document.title = `Pitch: ${pitchAngle.toFixed(2)} rad`;
// 根据俯仰角计算相机位置
// pitchAngle: 0.2 (俯视) 到 1.2 (较平视)
const horizontalRadius = zoom * Math.cos(pitchAngle);
const y = zoom * Math.sin(pitchAngle) + 2;
const camPos = new Vector3(
targetRef.current.x + Math.sin(angle) * horizontalRadius,
y,
targetRef.current.z + Math.cos(angle) * horizontalRadius
);
camera.position.lerp(camPos, Math.min(1, delta * 6));
camera.lookAt(targetRef.current.x, 0, targetRef.current.z);
});
return null;
};

View File

@@ -0,0 +1,133 @@
import { useMemo } from 'react';
import { Sky } from '@react-three/drei';
import { Vector3 } from 'three';
export type SkyPreset = 'sunrise' | 'day' | 'sunset' | 'night' | 'overcast' | 'stormy';
interface EnhancedSkyProps {
preset?: SkyPreset;
}
const SKY_PRESETS: Record<SkyPreset, {
sunPosition: [number, number, number];
turbidity: number;
rayleigh: number;
mieCoefficient: number;
mieDirectionalG: number;
}> = {
sunrise: {
sunPosition: [100, 5, 0], // 地平线附近,更低
turbidity: 10,
rayleigh: 3, // 增加散射,更明显的渐变
mieCoefficient: 0.005,
mieDirectionalG: 0.82,
},
day: {
sunPosition: [0, 60, 0], // 高但不在正上方,更好的渐变
turbidity: 3,
rayleigh: 1.5, // 增加蓝色渐变
mieCoefficient: 0.005,
mieDirectionalG: 0.7,
},
sunset: {
sunPosition: [-100, 5, 0], // 地平线附近,更低
turbidity: 10,
rayleigh: 3, // 增加散射
mieCoefficient: 0.005,
mieDirectionalG: 0.82,
},
night: {
sunPosition: [0, -20, 0], // 地平线以下更深
turbidity: 1,
rayleigh: 0.1,
mieCoefficient: 0.001,
mieDirectionalG: 0.5,
},
overcast: {
sunPosition: [0, 50, 0],
turbidity: 40, // 更浓厚
rayleigh: 0.3,
mieCoefficient: 0.02,
mieDirectionalG: 0.6,
},
stormy: {
sunPosition: [-50, 20, -30],
turbidity: 60, // 极浓厚
rayleigh: 0.5,
mieCoefficient: 0.03,
mieDirectionalG: 0.7,
},
};
export const EnhancedSky: React.FC<EnhancedSkyProps> = ({ preset = 'day' }) => {
const config = useMemo(() => SKY_PRESETS[preset], [preset]);
const sunPosition = useMemo(() => new Vector3(...config.sunPosition), [preset]);
return (
<Sky
distance={5000}
sunPosition={sunPosition}
turbidity={config.turbidity}
rayleigh={config.rayleigh}
mieCoefficient={config.mieCoefficient}
mieDirectionalG={config.mieDirectionalG}
/>
);
};
// 根据天空预设返回对应的光照配置
export const getLightingConfig = (preset: SkyPreset) => {
const configs: Record<SkyPreset, {
ambientColor: string;
ambientIntensity: number;
directionalColor: string;
directionalIntensity: number;
directionalPosition: [number, number, number];
}> = {
sunrise: {
ambientColor: '#ffecd2',
ambientIntensity: 0.4,
directionalColor: '#ffb347',
directionalIntensity: 1.2,
directionalPosition: [-100, 20, -50],
},
day: {
ambientColor: '#ffffff',
ambientIntensity: 0.8, // 显著增加环境光
directionalColor: '#ffffff',
directionalIntensity: 2.0, // 增加方向光
directionalPosition: [-80, 120, 30],
},
sunset: {
ambientColor: '#ffe4b5',
ambientIntensity: 0.45,
directionalColor: '#ff8c42',
directionalIntensity: 1.3,
directionalPosition: [-120, 40, 20],
},
night: {
ambientColor: '#1a1a2e',
ambientIntensity: 0.2,
directionalColor: '#4a5568',
directionalIntensity: 0.3,
directionalPosition: [0, -50, 0],
},
overcast: {
ambientColor: '#e0e0e0',
ambientIntensity: 0.6,
directionalColor: '#c0c0c0',
directionalIntensity: 0.8,
directionalPosition: [0, 80, 0],
},
stormy: {
ambientColor: '#6b7280',
ambientIntensity: 0.35,
directionalColor: '#9ca3af',
directionalIntensity: 0.7,
directionalPosition: [-50, 60, -30],
},
};
return configs[preset];
};

View File

@@ -0,0 +1,105 @@
import React, { useEffect, useRef } from 'react';
import { useThree, useFrame } from '@react-three/fiber';
import { Vector3, MathUtils, MOUSE } from 'three';
import { MapControls } from '@react-three/drei';
export const GameControls: React.FC = () => {
const { camera, gl } = useThree();
const controlsRef = useRef<any>(null);
// Target Zoom Level
const zoomRef = useRef(20); // Initial distance
const targetZoom = useRef(20);
// Reset camera function
const resetCamera = () => {
if (controlsRef.current) {
controlsRef.current.target.set(0, 0, 0);
controlsRef.current.object.position.set(20, 20, 20);
targetZoom.current = 20;
zoomRef.current = 20;
controlsRef.current.update();
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const step = 1;
if (!controlsRef.current) return;
// Arrow Keys for Panning (Manual offset if needed, but MapControls handles dragging)
// We can inject movement into target
const target = controlsRef.current.target;
// Pan logic via keys
if (e.key === 'ArrowUp') target.z -= step;
if (e.key === 'ArrowDown') target.z += step;
if (e.key === 'ArrowLeft') target.x -= step;
if (e.key === 'ArrowRight') target.x += step;
// Zoom logic via W/S
if (e.key === 'w' || e.key === 'W') {
targetZoom.current = Math.max(5, targetZoom.current - 5);
}
if (e.key === 's' || e.key === 'S') {
targetZoom.current = Math.min(50, targetZoom.current + 5);
}
// Reset
if (e.code === 'Space') {
resetCamera();
}
};
const handleWheel = (e: WheelEvent) => {
// Override default zoom slightly or just track it
// MapControls handles wheel zoom natively, but we want to sync our "targetZoom" state
// so WS keys don't jump.
// Actually, let MapControls handle wheel, we handle WS by moving camera along forward vector.
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
useFrame(({ clock }, delta) => {
if (!controlsRef.current) return;
// Smooth Zoom for W/S keys
// We manually interpolate the camera distance if W/S changed targetZoom
// Note: This might fight with OrbitControls internal zoom if not careful.
// Strategy: Check distance, lerp to targetZoom.
const currentDist = camera.position.distanceTo(controlsRef.current.target);
if (Math.abs(currentDist - targetZoom.current) > 0.1) {
// Move camera towards target to reach distance
const dir = new Vector3().subVectors(camera.position, controlsRef.current.target).normalize();
const newPos = controlsRef.current.target.clone().add(dir.multiplyScalar(MathUtils.lerp(currentDist, targetZoom.current, delta * 5)));
camera.position.copy(newPos);
}
});
return (
<MapControls
ref={controlsRef}
args={[camera, gl.domElement]}
enableDamping={true}
dampingFactor={0.1}
rotateSpeed={0.5}
panSpeed={1.0}
zoomSpeed={1.0}
minDistance={5}
maxDistance={60}
// Left Mouse = Pan (Default is Rotate for Orbit, but MapControls defaults to Pan for Left?)
// MapControls: Left Click = Pan, Right Click = Rotate. This matches RTS style nicely.
// User asked for "Left drag move camera". MapControls does exactly this by default.
mouseButtons={{
LEFT: MOUSE.PAN,
MIDDLE: MOUSE.DOLLY,
RIGHT: MOUSE.ROTATE
}}
screenSpacePanning={false} // True = Pan in screen plane, False = Pan on ground plane (XZ)
/>
);
};

View File

@@ -0,0 +1,201 @@
import { useMemo } from 'react';
import { shaderMaterial } from '@react-three/drei';
import { extend, type ReactThreeFiber } from '@react-three/fiber';
import { Color, Vector3, BackSide } from 'three';
export type SkyPreset = 'sunrise' | 'day' | 'sunset' | 'night' | 'overcast' | 'stormy';
interface MinecraftSkyProps {
preset: SkyPreset;
}
const SKY_PRESETS: Record<
SkyPreset,
{
topColor: string;
horizonColor: string;
bottomColor: string;
sunDirection: [number, number, number];
sunColor: string;
}
> = {
sunrise: {
topColor: '#fdd1a5',
horizonColor: '#ff9b58',
bottomColor: '#ffd7b8',
sunDirection: [0.2, 0.4, -0.3],
sunColor: '#ffd9a0',
},
day: {
topColor: '#8fd3ff',
horizonColor: '#c3ecff',
bottomColor: '#f6fcff',
sunDirection: [-0.35, 0.55, 0.15],
sunColor: '#ffe6c5',
},
sunset: {
topColor: '#ff8f70',
horizonColor: '#ffad53',
bottomColor: '#ffd9c0',
sunDirection: [-0.3, 0.35, 0.25],
sunColor: '#ffc26d',
},
night: {
topColor: '#112045',
horizonColor: '#1a2f59',
bottomColor: '#1f2b40',
sunDirection: [0.1, -0.4, 0.3],
sunColor: '#6f7cff',
},
overcast: {
topColor: '#b7c0ce',
horizonColor: '#cdd3df',
bottomColor: '#e2e6ef',
sunDirection: [0.15, 0.45, -0.2],
sunColor: '#f0f0f0',
},
stormy: {
topColor: '#5a6a82',
horizonColor: '#76849b',
bottomColor: '#8d9ab1',
sunDirection: [-0.1, 0.35, -0.4],
sunColor: '#dfe4f5',
},
};
const MinecraftSkyMaterial = shaderMaterial(
{
topColor: new Color('#6fc0ff'),
horizonColor: new Color('#9ed9ff'),
bottomColor: new Color('#d8f2ff'),
sunDirection: new Vector3(0, 1, 0),
sunColor: new Color('#fff0b8'),
},
`
varying vec3 vWorldPosition;
void main() {
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = normalize(worldPosition.xyz);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`,
`
varying vec3 vWorldPosition;
uniform vec3 topColor;
uniform vec3 horizonColor;
uniform vec3 bottomColor;
uniform vec3 sunDirection;
uniform vec3 sunColor;
void main() {
float height = clamp(vWorldPosition.y * 0.5 + 0.5, 0.0, 1.0);
vec3 baseColor = mix(bottomColor, horizonColor, smoothstep(0.0, 0.4, height));
baseColor = mix(baseColor, topColor, smoothstep(0.3, 1.0, height));
float sunFactor = max(0.0, dot(normalize(vWorldPosition), normalize(sunDirection)));
float sunGlow = pow(sunFactor, 400.0);
vec3 color = baseColor + sunColor * sunGlow;
gl_FragColor = vec4(color, 1.0);
}
`
);
extend({ MinecraftSkyMaterial });
declare module '@react-three/fiber' {
interface ThreeElements {
minecraftSkyMaterial: ReactThreeFiber.ElementProps<typeof MinecraftSkyMaterial> & {
attach?: string;
};
}
}
export const MinecraftSky: React.FC<MinecraftSkyProps> = ({ preset }) => {
const config = SKY_PRESETS[preset];
const uniforms = useMemo(
() => ({
topColor: new Color(config.topColor),
horizonColor: new Color(config.horizonColor),
bottomColor: new Color(config.bottomColor),
sunDirection: new Vector3(...config.sunDirection).normalize(),
sunColor: new Color(config.sunColor),
}),
[preset, config.bottomColor, config.horizonColor, config.sunColor, config.topColor, config.sunDirection]
);
return (
<mesh scale={[1200, 1200, 1200]} frustumCulled={false}>
<sphereGeometry args={[1, 32, 32]} />
<minecraftSkyMaterial
attach="material"
side={BackSide}
topColor={uniforms.topColor}
horizonColor={uniforms.horizonColor}
bottomColor={uniforms.bottomColor}
sunDirection={uniforms.sunDirection}
sunColor={uniforms.sunColor}
/>
</mesh>
);
};
export const getLightingConfig = (preset: SkyPreset) => {
const configs: Record<
SkyPreset,
{
ambientColor: string;
ambientIntensity: number;
directionalColor: string;
directionalIntensity: number;
directionalPosition: [number, number, number];
}
> = {
sunrise: {
ambientColor: '#ffe8d1',
ambientIntensity: 0.7, // 从0.45增加到0.7
directionalColor: '#ffb870',
directionalIntensity: 1.6, // 从1.1增加到1.6
directionalPosition: [50, 45, -30],
},
day: {
ambientColor: '#ffffff',
ambientIntensity: 1.0, // 从0.75增加到1.0
directionalColor: '#fff3d1',
directionalIntensity: 2.5, // 从2.0增加到2.5
directionalPosition: [-80, 120, 30],
},
sunset: {
ambientColor: '#ffd7b2',
ambientIntensity: 0.75, // 从0.5增加到0.75
directionalColor: '#ff9f5f',
directionalIntensity: 1.8, // 从1.3增加到1.8
directionalPosition: [-80, 55, 30],
},
night: {
ambientColor: '#1b2238',
ambientIntensity: 0.3, // 从0.18增加到0.3
directionalColor: '#7087ff',
directionalIntensity: 0.4, // 从0.25增加到0.4
directionalPosition: [40, -50, -20],
},
overcast: {
ambientColor: '#cfd5df',
ambientIntensity: 0.85, // 从0.65增加到0.85
directionalColor: '#c6ccd9',
directionalIntensity: 1.2, // 从0.9增加到1.2
directionalPosition: [0, 90, 0],
},
stormy: {
ambientColor: '#8d96a9',
ambientIntensity: 0.6, // 从0.4增加到0.6
directionalColor: '#b0b9cc',
directionalIntensity: 0.9, // 从0.6增加到0.9
directionalPosition: [-40, 60, -50],
},
};
return configs[preset];
};

View File

@@ -0,0 +1,120 @@
import { create } from 'zustand';
import { useUnitStore } from '../Units/store';
import { getReachableTiles } from '../../utils/pathfinding';
import { useMapStore } from '../Map/store';
export type BattlePhase = 'IDLE' | 'PLAYER_TURN' | 'AI_TURN' | 'VICTORY' | 'DEFEAT';
interface BattleState {
phase: BattlePhase;
activeUnitId: string | null;
reachableTiles: { x: number; y: number; z: number }[]; // For visualization
startBattle: () => void;
nextTurn: () => void;
endTurn: () => void;
moveActiveUnit: (to: { x: number; z: number }) => void;
}
export const useBattleStore = create<BattleState>((set, get) => ({
phase: 'IDLE',
activeUnitId: null,
reachableTiles: [],
startBattle: () => {
get().nextTurn();
},
nextTurn: () => {
const unitStore = useUnitStore.getState();
const mapStore = useMapStore.getState();
const units = unitStore.units;
if (units.length === 0) return;
let pendingUnits = [...units];
let readyUnit = pendingUnits.find(u => u.ct >= 100);
let loops = 0;
while (!readyUnit && loops < 1000) {
let minTicks = Infinity;
pendingUnits.forEach(u => {
const ticks = Math.max(0, (100 - u.ct) / u.stats.speed);
if (ticks < minTicks) minTicks = ticks;
});
minTicks = Math.ceil(minTicks) || 1;
const newUnits = pendingUnits.map(u => ({
...u,
ct: u.ct + u.stats.speed * minTicks
}));
pendingUnits = newUnits;
readyUnit = pendingUnits.find(u => u.ct >= 100);
loops++;
useUnitStore.setState({ units: pendingUnits });
}
if (!readyUnit) return;
pendingUnits.sort((a, b) => b.ct - a.ct);
const active = pendingUnits[0];
set({
activeUnitId: active.id,
phase: active.team === 'player' ? 'PLAYER_TURN' : 'AI_TURN'
});
unitStore.selectUnit(active.id);
// If Player Turn, calculate movement range
if (active.team === 'player') {
const tiles = getReachableTiles(
active.position,
active.stats.move,
active.stats.jump,
mapStore.getHeightAt,
mapStore.size
);
set({ reachableTiles: tiles });
} else {
set({ reachableTiles: [] });
// Simple AI: Wait 1s then end turn
setTimeout(() => {
get().endTurn();
}, 1000);
}
},
moveActiveUnit: (to) => {
const state = get();
if (state.phase !== 'PLAYER_TURN' || !state.activeUnitId) return;
// Check if valid move
const valid = state.reachableTiles.some(t => t.x === to.x && t.z === to.z);
if (!valid) return;
const unitStore = useUnitStore.getState();
const mapStore = useMapStore.getState();
const y = mapStore.getHeightAt(to.x, to.z) - 1;
unitStore.moveUnit(state.activeUnitId, { x: to.x, y, z: to.z });
set({ reachableTiles: [] }); // Clear highlights
// In a real game, we'd switch to ACTION_SELECT phase here
},
endTurn: () => {
const state = get();
const unitStore = useUnitStore.getState();
if (!state.activeUnitId) return;
const unitIndex = unitStore.units.findIndex(u => u.id === state.activeUnitId);
if (unitIndex >= 0) {
const units = [...unitStore.units];
units[unitIndex] = { ...units[unitIndex], ct: units[unitIndex].ct - 100 };
useUnitStore.setState({ units });
}
set({ activeUnitId: null, phase: 'IDLE', reachableTiles: [] });
setTimeout(() => get().nextTurn(), 100);
}
}));

View File

@@ -0,0 +1,311 @@
import React, { useLayoutEffect, useMemo, useRef } from 'react';
import { InstancedMesh, Object3D, Color, BufferGeometry, Float32BufferAttribute } from 'three';
import { useMapStore } from '../store';
import { useUnitStore } from '../../Units/store';
import { VOXEL_SIZE, TILE_SIZE, TREE_VOXEL_SIZE, type VoxelType } from '../logic/terrain';
import type { ThreeEvent } from '@react-three/fiber';
const tempObject = new Object3D();
const tempColor = new Color();
interface VoxelLayerProps {
// Update to include ix, iy, iz which are present in actual VoxelData
data: { x: number; y: number; z: number; color: string; heightScale?: number; isHighRes?: boolean; ix: number; iy: number; iz: number }[];
isHighRes: boolean;
type: VoxelType;
onClick?: (x: number, z: number) => void;
}
/**
* 专门用于水体的网格渲染器
* 使用 Greedy Meshing / Face Culling 思想,去除相邻水体之间的内部面
* 解决半透明材质叠加导致的颗粒感和反射问题
*/
const WaterFlowMesh: React.FC<VoxelLayerProps> = ({ data, isHighRes, type, onClick }) => {
const meshRef = useRef<any>(null);
const geometry = useMemo(() => {
if (!data.length) return new BufferGeometry();
// 1. 建立查找表 (Set of "ix|iy|iz")
const lookup = new Set<string>();
data.forEach(v => lookup.add(`${v.ix}|${v.iy}|${v.iz}`));
const positions: number[] = [];
const normals: number[] = [];
const colors: number[] = [];
// const indices: number[] = []; // Not strictly needed if not sharing vertices
const voxelSize = isHighRes ? TREE_VOXEL_SIZE : VOXEL_SIZE;
const halfSize = voxelSize / 2;
// 6个方向的邻居偏移和法线
const dirs = [
{ name: 'right', off: [1, 0, 0], normal: [1, 0, 0] },
{ name: 'left', off: [-1, 0, 0], normal: [-1, 0, 0] },
{ name: 'top', off: [0, 1, 0], normal: [0, 1, 0] },
{ name: 'bottom', off: [0, -1, 0], normal: [0, -1, 0] },
{ name: 'front', off: [0, 0, 1], normal: [0, 0, 1] },
{ name: 'back', off: [0, 0, -1], normal: [0, 0, -1] },
];
data.forEach(v => {
const { x, y, z, ix, iy, iz, color, heightScale = 1 } = v;
const col = new Color(color);
// 只有当 heightScale 为 1 时才能完美拼接
// 如果有缩放,简单起见仍然按照满格判定邻居,或者忽略拼接优化(水通常是满格)
const isFullBlock = Math.abs(heightScale - 1) < 0.01;
dirs.forEach(dir => {
// 检查该方向是否有同类邻居
const neighborKey = `${ix + dir.off[0]}|${iy + dir.off[1]}|${iz + dir.off[2]}`;
const hasNeighbor = isFullBlock && lookup.has(neighborKey);
// 如果没有邻居,渲染该面
if (!hasNeighbor) {
// 生成面的4个顶点
// 中心点 (x, y, z) 是体素的中心吗?
// InstancedMesh logic: y + (hScale - 1) * (size/2). If hScale=1, y is center.
// VoxelData.y is world coordinate.
// Assuming voxel.y is the center Y coordinate of the block if scale is 1.
// Calculate actual center based on heightScale logic from VoxelLayer
const centerY = y + (heightScale - 1) * (voxelSize / 2);
const scaleY = voxelSize * heightScale;
const halfY = scaleY / 2;
// Base vertex offsets for a unit cube centered at 0,0,0
// Right: (+h, -h, +h), (+h, -h, -h), (+h, +h, -h), (+h, +h, +h)
// Need to be careful with coordinate system
// THREE.js: Y up. Right(x+), Left(x-), Top(y+), Bottom(y-), Front(z+), Back(z-)
// Vertices relative to center
let v1, v2, v3, v4;
const hs = halfSize;
const hy = halfY;
// Using dir.normal to determine face
const [nx, ny, nz] = dir.normal;
// Logic to generate quad vertices
if (nx === 1) { // Right
v1 = [hs, -hy, hs]; v2 = [hs, -hy, -hs]; v3 = [hs, hy, -hs]; v4 = [hs, hy, hs];
} else if (nx === -1) { // Left
v1 = [-hs, -hy, -hs]; v2 = [-hs, -hy, hs]; v3 = [-hs, hy, hs]; v4 = [-hs, hy, -hs];
} else if (ny === 1) { // Top
v1 = [-hs, hy, hs]; v2 = [ hs, hy, hs]; v3 = [ hs, hy, -hs]; v4 = [-hs, hy, -hs];
} else if (ny === -1) { // Bottom
v1 = [ hs, -hy, hs]; v2 = [-hs, -hy, hs]; v3 = [-hs, -hy, -hs]; v4 = [ hs, -hy, -hs];
} else if (nz === 1) { // Front (Z+)
v1 = [-hs, -hy, hs]; v2 = [ hs, -hy, hs]; v3 = [ hs, hy, hs]; v4 = [-hs, hy, hs];
} else if (nz === -1) { // Back (Z-)
v1 = [ hs, -hy, -hs]; v2 = [-hs, -hy, -hs]; v3 = [-hs, hy, -hs]; v4 = [ hs, hy, -hs];
} else {
// Should not happen
v1=[0,0,0]; v2=[0,0,0]; v3=[0,0,0]; v4=[0,0,0];
}
// Translate to world pos
const applyPos = (v: number[]) => [v[0] + x, v[1] + centerY, v[2] + z];
const p1 = applyPos(v1);
const p2 = applyPos(v2);
const p3 = applyPos(v3);
const p4 = applyPos(v4);
// Push 2 triangles (CCW winding)
// Tri 1: p1, p2, p3
positions.push(...p1, ...p2, ...p3);
// Tri 2: p1, p3, p4
positions.push(...p1, ...p3, ...p4);
// Normals (same for all 6 verts)
for(let k=0; k<6; k++) normals.push(nx, ny, nz);
// Colors (same for all 6 verts)
for(let k=0; k<6; k++) colors.push(col.r, col.g, col.b);
}
});
});
const bufGeom = new BufferGeometry();
bufGeom.setAttribute('position', new Float32BufferAttribute(positions, 3));
bufGeom.setAttribute('normal', new Float32BufferAttribute(normals, 3));
bufGeom.setAttribute('color', new Float32BufferAttribute(colors, 3));
return bufGeom;
}, [data, isHighRes]);
return (
<mesh
ref={meshRef}
geometry={geometry}
castShadow
receiveShadow
>
<meshStandardMaterial
vertexColors
roughness={0.1}
metalness={0.2}
transparent={true}
opacity={0.75}
alphaTest={0}
depthWrite={false} // Water doesn't need depth write usually to avoid occlusion artifacts with itself
/>
</mesh>
);
}
const VoxelLayer: React.FC<VoxelLayerProps> = ({ data, isHighRes, type, onClick }) => {
const meshRef = useRef<InstancedMesh<any, any> | null>(null);
const setHoveredTile = useUnitStore(state => state.setHoveredTile);
useLayoutEffect(() => {
const mesh = meshRef.current;
if (!mesh || data.length === 0) return;
// 明确使用配置的尺寸
const voxelSize = isHighRes ? TREE_VOXEL_SIZE : VOXEL_SIZE;
data.forEach((voxel, i) => {
const hScale = voxel.heightScale ?? 1;
const scaleY = voxelSize * hScale;
tempObject.position.set(
voxel.x,
voxel.y + (hScale - 1) * (voxelSize / 2),
voxel.z
);
tempObject.scale.set(voxelSize, scaleY, voxelSize);
tempObject.updateMatrix();
mesh.setMatrixAt(i, tempObject.matrix);
tempColor.set(voxel.color);
mesh.setColorAt(i, tempColor);
});
mesh.instanceMatrix.needsUpdate = true;
}, [data, isHighRes]);
const handlePointerMove = (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
if (e.instanceId === undefined) return;
const voxel = data[e.instanceId];
if (voxel) {
const lx = Math.floor(voxel.x / TILE_SIZE);
const lz = Math.floor(voxel.z / TILE_SIZE);
setHoveredTile({ x: lx, z: lz });
}
};
const handleClick = (e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
if (e.instanceId === undefined || !onClick) return;
const voxel = data[e.instanceId];
if (voxel) {
const lx = Math.floor(voxel.x / TILE_SIZE);
const lz = Math.floor(voxel.z / TILE_SIZE);
onClick(lx, lz);
}
}
const materialProps = useMemo(() => {
// Water is now handled by WaterFlowMesh, but keeping this for fallback or other transparent blocks
if (type === 'water') {
return {
roughness: 0.1,
metalness: 0.2,
transparent: true,
opacity: 0.75
};
} else if (type === 'ice') {
return {
roughness: 0.1,
metalness: 0.1,
transparent: true,
opacity: 0.8
};
}
return {
roughness: 0.9,
metalness: 0.2,
transparent: false,
opacity: 1.0
};
}, [type]);
return (
<instancedMesh
ref={meshRef}
args={[undefined, undefined, data.length]}
castShadow
receiveShadow
onPointerMove={handlePointerMove}
onClick={handleClick}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial
roughness={materialProps.roughness}
metalness={materialProps.metalness}
transparent={materialProps.transparent}
opacity={materialProps.opacity}
alphaTest={0}
depthWrite={!materialProps.transparent}
/>
</instancedMesh>
);
};
interface ChunkRendererProps {
onVoxelClick?: (x: number, z: number) => void;
}
export const ChunkRenderer: React.FC<ChunkRendererProps> = ({ onVoxelClick }) => {
const voxels = useMapStore((state) => state.voxels);
const groupedVoxels = useMemo(() => {
const groups: Record<string, typeof voxels> = {};
voxels.forEach((v) => {
const key = `${v.type}|${v.isHighRes ? 'true' : 'false'}`;
if (!groups[key]) groups[key] = [];
groups[key].push(v);
});
return groups;
}, [voxels]);
return (
<group>
{Object.keys(groupedVoxels).map((key) => {
const [type, isHighResStr] = key.split('|');
const isHighRes = isHighResStr === 'true';
// 特殊处理水体:使用 Face Culling 网格来消除内部面
if (type === 'water') {
return (
<WaterFlowMesh
key={key}
isHighRes={isHighRes}
type={type as VoxelType}
// @ts-ignore: voxels contain ix/iy/iz but type def in props might be strict
data={groupedVoxels[key]}
onClick={onVoxelClick}
/>
);
}
return (
<VoxelLayer
key={key}
isHighRes={isHighRes}
type={type as VoxelType}
// @ts-ignore
data={groupedVoxels[key]}
onClick={onVoxelClick}
/>
);
})}
</group>
);
};

View File

@@ -0,0 +1,41 @@
import React, { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { Mesh, BoxGeometry } from 'three';
import { useUnitStore } from '../../Units/store';
import { useMapStore } from '../store';
import { TILE_SIZE } from '../logic/terrain';
export const SelectionCursor: React.FC = () => {
const hoveredTile = useUnitStore((state) => state.hoveredTile);
const getHeightAt = useMapStore((state) => state.getHeightAt);
const meshRef = useRef<Mesh>(null);
useFrame(({ clock }) => {
if (!hoveredTile || !meshRef.current) {
if (meshRef.current) meshRef.current.visible = false;
return;
}
meshRef.current.visible = true;
const worldY = getHeightAt(hoveredTile.x, hoveredTile.z);
// Returns Unit Y (Top of block).
// Cursor should be at Block Y (Top - VOXEL_SIZE/2?) or just slightly above block.
// Logic Coords -> World Center
const cx = hoveredTile.x * TILE_SIZE + TILE_SIZE / 2;
const cz = hoveredTile.z * TILE_SIZE + TILE_SIZE / 2;
meshRef.current.position.set(cx, worldY - 0.1, cz); // Slightly below unit feet
// Pulse
const s = (TILE_SIZE * 1.05) + Math.sin(clock.elapsedTime * 10) * 0.05;
meshRef.current.scale.set(s, s, s);
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 0.2, 1]} /> {/* Flattened box */}
<meshBasicMaterial color="yellow" wireframe transparent opacity={0.5} />
</mesh>
);
};

View File

@@ -0,0 +1,55 @@
import React, { useLayoutEffect, useRef } from 'react';
import { InstancedMesh, Object3D } from 'three';
import { useBattleStore } from '../../Battle/store';
import { TILE_SIZE } from '../logic/terrain';
const tempObject = new Object3D();
export const MoveOverlay: React.FC = () => {
const reachableTiles = useBattleStore(state => state.reachableTiles);
const meshRef = useRef<InstancedMesh>(null);
useLayoutEffect(() => {
if (!meshRef.current) return;
reachableTiles.forEach((tile, i) => {
// Tile x,z are Logic Coords.
// We need to convert to World Coords (Center of tile).
// Tile goes from x*SIZE to (x+1)*SIZE. Center is x*SIZE + SIZE/2.
const cx = tile.x * TILE_SIZE + TILE_SIZE / 2;
const cz = tile.z * TILE_SIZE + TILE_SIZE / 2;
// Render slightly above the ground
// tile.y is World Y
tempObject.position.set(cx - 0.5 * TILE_SIZE, tile.y + 0.1, cz - 0.5 * TILE_SIZE); // Offset correction
// Wait, logic tile 5. Center is 5.5?
// Let's check terrain generation.
// voxels.x = lx * MICRO + mx. Scaled by VOXEL_SIZE.
// lx * TILE_SIZE <= voxel.x < (lx+1)*TILE_SIZE.
// So yes, logic tile covers [lx, lx+1].
// MoveOverlay should cover this area.
tempObject.position.set(tile.x * TILE_SIZE + TILE_SIZE/2, tile.y + 0.2, tile.z * TILE_SIZE + TILE_SIZE/2);
tempObject.scale.set(TILE_SIZE * 0.9, 1, TILE_SIZE * 0.9); // Almost fill the tile
tempObject.updateMatrix();
meshRef.current!.setMatrixAt(i, tempObject.matrix);
});
meshRef.current.instanceMatrix.needsUpdate = true;
meshRef.current.count = reachableTiles.length;
}, [reachableTiles]);
if (reachableTiles.length === 0) return null;
return (
<instancedMesh
ref={meshRef}
args={[undefined, undefined, reachableTiles.length]}
>
<planeGeometry args={[1, 1]} rotation={[-Math.PI / 2, 0, 0]} />
<meshBasicMaterial color="#00aaff" transparent opacity={0.5} depthWrite={false} />
</instancedMesh>
);
};

View File

@@ -0,0 +1,144 @@
/**
* 地形生成进度条组件
* 显示地形生成的各个阶段和进度
* 优化版本:简洁精致的设计
*/
import { useMapStore } from '../store';
export const TerrainGenerationProgress = () => {
const { isGenerating, generationProgress } = useMapStore();
if (!isGenerating || !generationProgress) {
return null;
}
const { progress, detail } = generationProgress;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-black/50 backdrop-blur-sm rounded-lg shadow-2xl p-6 min-w-[420px] border border-white/10">
{/* 进度百分比 - 居中突出显示 */}
<div className="text-center mb-6">
<div className="text-4xl font-bold text-white font-mono tracking-wider">
{progress}%
</div>
</div>
{/* 阶段指示器 - 步骤圆圈 */}
<div className="mb-5 flex justify-between items-center">
{['basic', 'features', 'postprocessing', 'vegetation'].map((stage, index) => {
const stageLabels = {
basic: '基础地形',
features: '特征地形',
postprocessing: '后处理',
vegetation: '植被'
};
const isCompleted = getStageIndex(generationProgress.stage) > index;
const isCurrent = generationProgress.stage === stage;
return (
<div key={stage} className="flex flex-col items-center gap-2">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold transition-all duration-500 ${
isCompleted
? 'bg-emerald-500 text-white shadow-lg shadow-emerald-500/50 scale-105'
: isCurrent
? 'bg-cyan-400 text-white shadow-lg shadow-cyan-400/60 scale-110 animate-pulse-subtle ring-2 ring-cyan-300/30'
: 'bg-white/10 text-white/40 backdrop-blur-sm'
}`}
>
{isCompleted ? '✓' : index + 1}
</div>
<span
className={`text-xs transition-all duration-500 ${
isCompleted || isCurrent
? 'text-white font-medium opacity-100'
: 'text-white/40 opacity-70'
}`}
>
{stageLabels[stage as keyof typeof stageLabels]}
</span>
</div>
);
})}
</div>
{/* 精致进度条 */}
<div className="relative h-2.5 bg-white/5 rounded-full overflow-hidden backdrop-blur-sm shadow-inner">
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-emerald-400 via-cyan-400 to-blue-400 rounded-full transition-all duration-700 ease-out shadow-lg shadow-cyan-400/50 animate-glow"
style={{ width: `${progress}%` }}
>
{/* 精致光效层 1: 流动光泽 */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent animate-shimmer" />
{/* 精致光效层 2: 顶部高光 */}
<div className="absolute inset-x-0 top-0 h-1/2 bg-gradient-to-b from-white/30 to-transparent rounded-full" />
</div>
</div>
{/* 细节信息 - 低调显示 */}
{detail && (
<div className="mt-3 text-center">
<p className="text-[10px] text-white/30 italic tracking-wide">
{detail}
</p>
</div>
)}
</div>
{/* 精致CSS动画 */}
<style>{`
/* 流动光泽动画 - 更慢更优雅 */
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2.5s ease-in-out infinite;
}
/* 微妙的脉冲动画 - 用于当前步骤 */
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
transform: scale(1.1);
}
50% {
opacity: 0.85;
transform: scale(1.08);
}
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
/* 发光呼吸效果 - 用于进度条 */
@keyframes glow {
0%, 100% {
box-shadow: 0 0 20px rgba(34, 211, 238, 0.4), 0 0 40px rgba(34, 211, 238, 0.2);
}
50% {
box-shadow: 0 0 30px rgba(34, 211, 238, 0.6), 0 0 60px rgba(34, 211, 238, 0.3);
}
}
.animate-glow {
animation: glow 2s ease-in-out infinite;
}
`}</style>
</div>
);
};
// 辅助函数:获取阶段索引
function getStageIndex(stage: string): number {
const stages = ['basic', 'features', 'postprocessing', 'vegetation', 'complete'];
return stages.indexOf(stage);
}

View File

@@ -0,0 +1,605 @@
/**
* 戈壁特征地形系统:生成戈壁山丘、溪流、巨石等特殊地形
* - 戈壁山丘生成(普通山丘和拱形山丘)
* - 戈壁溪流生成
* - 戈壁岩石结构(巨石、石台、土台)
* - 戈壁分层逻辑
*/
import type { VoxelType } from './voxelStyles';
// MICRO_SCALE 常量:每个逻辑格子包含的微型体素数量
// const MICRO_SCALE = 8; // Unused
// ============= 常量定义 =============
export const DESERT_MAX_EXTRA_HEIGHT = 4; // 额外高度不超过 4 个逻辑层
// ============= 类型定义 =============
export interface DesertContext {
gobiHeight: number[][];
gobiVariant: number[][];
gobiMinHeight: number[][]; // 记录戈壁起始高度(用于拱门效果)
stoneHeight: number[][];
stoneVariant: number[][];
streamDepthMap: Map<string, { depth: number; waterHeight: number; isCenter: boolean }>; // Added isCenter
}
// ============= 戈壁岩石结构生成 =============
// (Legacy structures removed)
// ============= 通用形态学修正 =============
const applyMorphologicalSmoothing = (
heightMap: number[][],
startX: number,
startZ: number,
endX: number,
endZ: number,
mapSize: number
) => {
const changes: Array<{x: number, z: number, h: number}> = [];
const sx = Math.max(0, startX - 1);
const sz = Math.max(0, startZ - 1);
const ex = Math.min(mapSize - 1, endX + 1);
const ez = Math.min(mapSize - 1, endZ + 1);
for (let x = sx; x <= ex; x++) {
for (let z = sz; z <= ez; z++) {
const currentH = heightMap[x][z];
const n = (z > 0) ? heightMap[x][z-1] : 0;
const s = (z < mapSize - 1) ? heightMap[x][z+1] : 0;
const w = (x > 0) ? heightMap[x-1][z] : 0;
const e = (x < mapSize - 1) ? heightMap[x+1][z] : 0;
if (currentH === 0) {
const maxNeighborH = Math.max(n, s, w, e);
if (maxNeighborH > 0) {
let shouldFill = false;
if (n > 0 && w > 0) shouldFill = true;
if (n > 0 && e > 0) shouldFill = true;
if (s > 0 && w > 0) shouldFill = true;
if (s > 0 && e > 0) shouldFill = true;
if (n > 0 && s > 0) shouldFill = true;
if (w > 0 && e > 0) shouldFill = true;
if (shouldFill) {
changes.push({x, z, h: maxNeighborH});
}
}
}
}
}
for (const change of changes) {
if (heightMap[change.x][change.z] < change.h) {
heightMap[change.x][change.z] = change.h;
}
}
};
const spawnMesa = (
centerX: number,
centerZ: number,
radiusH: number,
radiusV: number,
height: number,
mapSize: number,
gobiHeight: number[][],
gobiVariant: number[][],
gobiMinHeight: number[][],
rand: () => number,
checkLimit?: () => boolean
): number => {
let cellsPlaced = 0;
const topFlatRatio = 0.6 + rand() * 0.2;
const hasRamp = true;
const rampAngle = rand() * Math.PI * 2;
const rampWidth = Math.PI / 2.5;
const rampLengthFactor = 1.4 + rand() * 0.4;
const hasArch = height >= 4 && rand() < 0.3;
let archDirection = 0;
const isUShape = !hasArch && radiusH > 3 && rand() < 0.25;
const uShapeAngle = rand() * Math.PI * 2;
const uShapeWidth = Math.PI / 2.5;
if (hasArch) {
archDirection = rand() < 0.5 ? 0 : 1;
}
const scanRadiusH = Math.ceil(radiusH * rampLengthFactor) + 2;
const scanRadiusV = Math.ceil(radiusV * rampLengthFactor) + 2;
const noiseOffset = rand() * 100;
const archCenterOffset = (rand() - 0.5) * 0.6 * (archDirection === 0 ? radiusH : radiusV);
const hasTier = rand() < 0.6;
const tierHeight = hasTier ? 1 + Math.floor(rand() * 2) : 0;
const tierWidthAdded = 1 + Math.floor(rand() * 2);
const scanRadiusH_Tier = scanRadiusH + tierWidthAdded + 1;
const scanRadiusV_Tier = scanRadiusV + tierWidthAdded + 1;
for (let dx = -scanRadiusH_Tier; dx <= scanRadiusH_Tier; dx++) {
for (let dz = -scanRadiusV_Tier; dz <= scanRadiusV_Tier; dz++) {
if (checkLimit && checkLimit()) return cellsPlaced;
const lx = centerX + dx;
const lz = centerZ + dz;
if (lx >= 0 && lx < mapSize && lz >= 0 && lz < mapSize) {
const angle = Math.atan2(dz, dx);
const noise = (Math.sin(angle * 3 + noiseOffset) * 0.5 + Math.cos(angle * 5) * 0.25) * 0.1;
const n = 2.5 + rand() * 1.0;
const rawDist = Math.pow(Math.pow(Math.abs(dx) / radiusH, n) + Math.pow(Math.abs(dz) / radiusV, n), 1/n);
let dist = rawDist + noise;
const rawDistTier = Math.pow(Math.pow(Math.abs(dx) / (radiusH + tierWidthAdded), n) + Math.pow(Math.abs(dz) / (radiusV + tierWidthAdded), n), 1/n);
let distTier = rawDistTier + noise;
let angleDiff = Math.abs(angle - rampAngle);
if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
if (hasRamp && angleDiff < rampWidth / 2) {
const rampFactor = rampLengthFactor;
const t = angleDiff / (rampWidth / 2);
const rampInfluence = (1 + Math.cos(t * Math.PI)) * 0.5;
dist = dist / (1 + (rampFactor - 1) * rampInfluence);
distTier = distTier / (1 + (rampFactor - 1) * rampInfluence);
}
if (isUShape) {
let uAngleDiff = Math.abs(angle - uShapeAngle);
if (uAngleDiff > Math.PI) uAngleDiff = 2 * Math.PI - uAngleDiff;
if (uAngleDiff < uShapeWidth / 2) {
const cutInfluence = Math.cos((uAngleDiff / (uShapeWidth / 2)) * (Math.PI / 2));
const centerDist = Math.sqrt(dx*dx + dz*dz) / Math.max(radiusH, radiusV);
if (centerDist < 0.8) {
dist += cutInfluence * 2.0;
distTier += cutInfluence * 2.0;
}
}
}
let h = 0;
if (dist < 1.0) {
if (dist < topFlatRatio) {
h = height;
} else {
const edgeProgress = (dist - topFlatRatio) / (1 - topFlatRatio);
const t = Math.max(0, Math.min(1, edgeProgress));
const smoothT = t * t * (3 - 2 * t);
h = Math.round(height * (1 - smoothT));
}
} else if (hasTier && distTier < 1.0) {
const tierFlatRatio = topFlatRatio * 0.9;
if (distTier < tierFlatRatio) {
h = tierHeight;
} else {
const edgeProgress = (distTier - tierFlatRatio) / (1 - tierFlatRatio);
const t = Math.max(0, Math.min(1, edgeProgress));
const smoothT = t * t * (3 - 2 * t);
h = Math.round(tierHeight * (1 - smoothT));
}
}
if (h > 0) {
let minLayerHeight = 0;
if (hasArch && h > tierHeight) {
const u = archDirection === 0 ? dx : dz;
const v = archDirection === 0 ? dz : dx;
const archSpan = (archDirection === 0 ? radiusH : radiusV) * 0.85;
const archThickness = (archDirection === 0 ? radiusV : radiusH) * 0.6;
if (Math.abs(v) < archThickness) {
const localU = u - archCenterOffset;
if (Math.abs(localU) < archSpan) {
const archHeightRatio = 1 - Math.pow(localU / archSpan, 2);
if (archHeightRatio > 0) {
const minCeiling = Math.max(3, Math.floor(h * 0.3));
const maxHoleHeight = Math.max(0, h - minCeiling);
let holeHeight = Math.floor(maxHoleHeight * (0.4 + 0.6 * archHeightRatio));
const asymmetry = 1 + (localU / archSpan) * 0.5;
holeHeight = Math.floor(holeHeight * asymmetry);
holeHeight = Math.max(0, Math.min(maxHoleHeight, holeHeight));
if (holeHeight > 0) {
minLayerHeight = holeHeight;
}
}
}
}
}
if (h > gobiHeight[lx][lz]) {
if (gobiHeight[lx][lz] === 0) cellsPlaced++;
gobiHeight[lx][lz] = h;
if (h !== gobiHeight[lx][lz]) {
gobiVariant[lx][lz] = Math.floor(rand() * 3);
}
gobiMinHeight[lx][lz] = minLayerHeight;
}
}
}
}
}
applyMorphologicalSmoothing(
gobiHeight,
centerX - scanRadiusH_Tier,
centerZ - scanRadiusV_Tier,
centerX + scanRadiusH_Tier,
centerZ + scanRadiusV_Tier,
mapSize
);
return cellsPlaced;
};
const spawnMiniMesa = (
centerX: number,
centerZ: number,
radius: number,
height: number,
mapSize: number,
gobiHeight: number[][],
gobiVariant: number[][],
gobiMinHeight: number[][],
rand: () => number,
checkLimit?: () => boolean
): number => {
let cellsPlaced = 0;
const topFlatRatio = 0.8;
const hasRamp = rand() < 0.3;
const rampAngle = rand() * Math.PI * 2;
const rampWidth = Math.PI / 2;
const scanRadius = Math.ceil(radius) + 2;
for (let dx = -scanRadius; dx <= scanRadius; dx++) {
for (let dz = -scanRadius; dz <= scanRadius; dz++) {
if (checkLimit && checkLimit()) return cellsPlaced;
const lx = centerX + dx;
const lz = centerZ + dz;
if (lx >= 0 && lx < mapSize && lz >= 0 && lz < mapSize) {
if (gobiHeight[lx][lz] >= height) continue;
const angle = Math.atan2(dz, dx);
const noise = (Math.sin(angle * 3)) * 0.05;
let dist = Math.sqrt(dx*dx + dz*dz) / radius + noise;
if (hasRamp) {
let angleDiff = Math.abs(angle - rampAngle);
if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
if (angleDiff < rampWidth / 2) {
const rampFactor = 1.4;
const rampInfluence = Math.cos((angleDiff / (rampWidth / 2)) * (Math.PI / 2));
dist = dist / (1 + (rampFactor - 1) * rampInfluence);
}
}
if (dist < 1.0) {
let h = 0;
if (dist < topFlatRatio) {
h = height;
} else {
h = Math.round(height * 0.6);
}
if (h > 0 && h > gobiHeight[lx][lz]) {
if (gobiHeight[lx][lz] === 0) cellsPlaced++;
gobiHeight[lx][lz] = h;
gobiVariant[lx][lz] = Math.floor(rand() * 3);
gobiMinHeight[lx][lz] = 0;
}
}
}
}
}
return cellsPlaced;
};
const layoutStrategicDesert = (
mapSize: number,
gobiHeight: number[][],
gobiVariant: number[][],
gobiMinHeight: number[][],
rand: () => number
) => {
console.log('[DesertGen] Using Strategic Segmentation Layout (Optimized)');
const getZoneWeight = (x: number, z: number): number => {
const distFromCenter = Math.sqrt((x - mapSize/2)**2 + (z - mapSize/2)**2);
const normalizedDist = distFromCenter / (mapSize / 2);
if (normalizedDist > 0.8) return 0.9;
if (normalizedDist < 0.5) return 0.05; // Expanded center clear zone (was 0.3 / 0.1)
return 0.4;
};
const reservedPaths = new Set<string>();
const pathCount = 4 + Math.floor(rand() * 2); // Increased paths
for (let i = 0; i < pathCount; i++) {
const startSide = Math.floor(rand() * 4);
const endSide = (startSide + 2) % 4;
const getEdgePoint = (side: number) => {
if (side === 0) return { x: Math.floor(rand() * mapSize), z: 0 };
if (side === 1) return { x: mapSize - 1, z: Math.floor(rand() * mapSize) };
if (side === 2) return { x: Math.floor(rand() * mapSize), z: mapSize - 1 };
return { x: 0, z: Math.floor(rand() * mapSize) };
};
let curr = getEdgePoint(startSide);
const target = getEdgePoint(endSide);
const pathWidth = 5 + Math.floor(rand() * 3); // Wider paths
let safety = 0;
while (Math.abs(curr.x - target.x) + Math.abs(curr.z - target.z) > 4 && safety++ < mapSize * 3) {
const dx = Math.sign(target.x - curr.x);
const dz = Math.sign(target.z - curr.z);
if (rand() > 0.2) {
if (Math.abs(target.x - curr.x) > Math.abs(target.z - curr.z)) curr.x += dx;
else curr.z += dz;
} else {
if (rand() > 0.5) curr.x += (rand() > 0.5 ? 1 : -1);
else curr.z += (rand() > 0.5 ? 1 : -1);
}
curr.x = Math.max(0, Math.min(mapSize - 1, curr.x));
curr.z = Math.max(0, Math.min(mapSize - 1, curr.z));
for (let px = -Math.floor(pathWidth/2); px <= Math.floor(pathWidth/2); px++) {
for (let pz = -Math.floor(pathWidth/2); pz <= Math.floor(pathWidth/2); pz++) {
const rx = curr.x + px;
const rz = curr.z + pz;
if (rx >= 0 && rx < mapSize && rz >= 0 && rz < mapSize) {
reservedPaths.add(`${rx}|${rz}`);
}
}
}
}
}
let totalGobiCells = 0;
const maxGobiCells = Math.floor(mapSize * mapSize * 0.22); // Reduced from 30%
const checkLimit = () => totalGobiCells >= maxGobiCells;
// Rule 1: Minimum 2 mesas (guarantee diversity even on small maps)
const targetMesas = Math.max(2, Math.floor(mapSize / 5));
let placedMesas = 0;
let attempts = 0;
// Helper to try placing a mesa
const tryPlaceMesa = (cx: number, cz: number, forced: boolean = false): boolean => {
if (reservedPaths.has(`${cx}|${cz}`) && !forced) return false;
// Weight check
if (!forced) {
const weight = getZoneWeight(cx, cz);
if (rand() > weight) return false;
}
const distFromCenter = Math.sqrt((cx - mapSize/2)**2 + (cz - mapSize/2)**2);
const normalizedDist = distFromCenter / (mapSize / 2);
const isEdge = normalizedDist > 0.7;
const maxSingleRadius = mapSize * 0.10;
let radius = isEdge ? 2.5 + rand() * 2.5 : 1.5 + rand() * 1.5;
// Rule 2: Smaller mesas for small maps (<=16)
if (mapSize <= 16) {
radius = 1.5 + rand() * 1.0; // Max 2.5
}
radius = Math.min(radius, maxSingleRadius);
const height = isEdge ? 4 + Math.floor(rand() * 2) : 3 + Math.floor(rand() * 2);
const safetyRadius = Math.ceil(radius) + 3;
let areaOccupied = false;
for (let dx = -safetyRadius; dx <= safetyRadius; dx++) {
for (let dz = -safetyRadius; dz <= safetyRadius; dz++) {
const nx = cx + dx;
const nz = cz + dz;
if (nx >= 0 && nx < mapSize && nz >= 0 && nz < mapSize) {
if (gobiHeight[nx][nz] > 0) {
areaOccupied = true;
break;
}
}
}
if (areaOccupied) break;
}
if (areaOccupied) return false;
const added = spawnMesa(cx, cz, radius, radius, height, mapSize, gobiHeight, gobiVariant, gobiMinHeight, rand, checkLimit);
totalGobiCells += added;
return added > 0;
};
while (placedMesas < targetMesas && attempts < 200 && !checkLimit()) {
attempts++;
let cx = Math.floor(rand() * mapSize);
let cz = Math.floor(rand() * mapSize);
// Rule 2 (Cont.): Edge Bias logic (stronger for small maps)
if (mapSize <= 16) {
// 80% chance to pick from outer ring if map is small
if (rand() < 0.8) {
const side = Math.floor(rand() * 4);
const depth = Math.floor(rand() * 3); // 0-2 from edge
if (side === 0) { cx = Math.floor(rand() * mapSize); cz = depth; }
else if (side === 1) { cx = mapSize - 1 - depth; cz = Math.floor(rand() * mapSize); }
else if (side === 2) { cx = Math.floor(rand() * mapSize); cz = mapSize - 1 - depth; }
else { cx = depth; cz = Math.floor(rand() * mapSize); }
}
}
if (tryPlaceMesa(cx, cz)) {
placedMesas++;
}
}
// Rule 3: Fallback / Guarantee Loop
// If we haven't met the minimum (2), force placement by scanning edges
let fallbackAttempts = 0;
while (placedMesas < 2 && fallbackAttempts < 100 && !checkLimit()) {
fallbackAttempts++;
// Try random edge positions explicitly ignoring weight
const side = Math.floor(rand() * 4);
const depth = Math.floor(rand() * 3); // 0-2 from edge
let cx, cz;
if (side === 0) { cx = Math.floor(rand() * mapSize); cz = depth; }
else if (side === 1) { cx = mapSize - 1 - depth; cz = Math.floor(rand() * mapSize); }
else if (side === 2) { cx = Math.floor(rand() * mapSize); cz = mapSize - 1 - depth; }
else { cx = depth; cz = Math.floor(rand() * mapSize); }
// Force logic: ignore zone weight, rely only on physical space check
if (tryPlaceMesa(cx, cz, true)) {
placedMesas++;
}
}
const miniTarget = Math.floor(mapSize / 5);
let placedMinis = 0;
attempts = 0;
while (placedMinis < miniTarget && attempts < 50 && !checkLimit()) {
attempts++;
const cx = Math.floor(rand() * mapSize);
const cz = Math.floor(rand() * mapSize);
if (reservedPaths.has(`${cx}|${cz}`)) continue;
if (gobiHeight[cx][cz] === 0) {
let tooClose = false;
for(let dx=-3; dx<=3; dx++) {
for(let dz=-3; dz<=3; dz++) {
const nx = cx+dx;
const nz = cz+dz;
if(nx>=0 && nx<mapSize && nz>=0 && nz<mapSize && gobiHeight[nx][nz]>0) {
tooClose=true; break;
}
}
if(tooClose) break;
}
if(tooClose) continue;
const added = spawnMiniMesa(cx, cz, 1.5 + rand(), 2, mapSize, gobiHeight, gobiVariant, gobiMinHeight, rand, checkLimit);
totalGobiCells += added;
if (added > 0) placedMinis++;
}
}
};
export const createDesertContext = (
mapSize: number,
rand: () => number
): DesertContext => {
const gobiHeight = Array.from({ length: mapSize }, () => Array(mapSize).fill(0));
const gobiVariant = Array.from({ length: mapSize }, () => Array(mapSize).fill(0));
const gobiMinHeight = Array.from({ length: mapSize }, () => Array(mapSize).fill(0));
const stoneHeight = Array.from({ length: mapSize }, () => Array(mapSize).fill(0));
const stoneVariant = Array.from({ length: mapSize }, () => Array(mapSize).fill(0));
layoutStrategicDesert(mapSize, gobiHeight, gobiVariant, gobiMinHeight, rand);
// Adjusted density: Ensure presence on 24/32 maps, occasional on 16
// 32x32 (1024) / 200 = ~5 stones
// 24x24 (576) / 200 = ~2-3 stones
// 16x16 (256) / 200 = ~1.2 -> handled specifically below
let targetStones = Math.floor((mapSize * mapSize) / 200);
if (mapSize <= 16) {
// For 16x16: 30% chance to spawn 1 stone, otherwise 0
targetStones = rand() > 0.7 ? 1 : 0;
}
let placedStones = 0;
let attempts = 0;
// Give plenty of attempts because the strict avoidance check will reject many positions
const maxAttempts = Math.max(20, targetStones * 50);
while (placedStones < targetStones && attempts < maxAttempts) {
attempts++;
const startX = Math.floor(rand() * mapSize);
const startZ = Math.floor(rand() * mapSize);
// Constraint: Max 3 blocks (1-3)
const targetSize = 1 + Math.floor(rand() * 3);
// Algorithm: Random Walker
const rockCells: Array<{x: number, z: number}> = [{x: startX, z: startZ}];
let safety = 0;
while (rockCells.length < targetSize && safety++ < 10) {
const seed = rockCells[Math.floor(rand() * rockCells.length)];
const dir = Math.floor(rand() * 4);
const dx = dir === 0 ? 1 : dir === 1 ? -1 : 0;
const dz = dir === 2 ? 1 : dir === 3 ? -1 : 0;
const nx = seed.x + dx;
const nz = seed.z + dz;
if (nx >= 0 && nx < mapSize && nz >= 0 && nz < mapSize) {
if (!rockCells.some(c => c.x === nx && c.z === nz)) {
rockCells.push({x: nx, z: nz});
}
}
}
let isValid = true;
const checkDist = 2;
for (const cell of rockCells) {
for (let dx = -checkDist; dx <= checkDist; dx++) {
for (let dz = -checkDist; dz <= checkDist; dz++) {
const cx = cell.x + dx;
const cz = cell.z + dz;
if (cx >= 0 && cx < mapSize && cz >= 0 && cz < mapSize) {
if (gobiHeight[cx][cz] > 0) {
isValid = false;
break;
}
if (stoneHeight[cx][cz] > 0) {
isValid = false;
break;
}
}
}
if (!isValid) break;
}
if (!isValid) break;
}
if (isValid) {
const h = (targetSize > 1 && rand() > 0.85) ? 2 : 1;
const variant = Math.floor(rand() * 2);
for (const cell of rockCells) {
stoneHeight[cell.x][cell.z] = h;
stoneVariant[cell.x][cell.z] = variant;
}
placedStones++;
}
}
const streamDepthMap = generateDesertStreamMap(mapSize, rand, gobiHeight, stoneHeight);
return {
gobiHeight,
gobiVariant,
gobiMinHeight,
stoneHeight,
stoneVariant,
streamDepthMap,
};
};
import { generateDesertRiver } from './waterSystem';
export const generateDesertStreamMap = (
mapSize: number,
rand: () => number,
gobiHeight: number[][],
stoneHeight: number[][]
) => {
// 使用新的基于微体素 A* 算法的河流生成系统
// 默认 MICRO_SCALE 为 8 (由 waterSystem 内部默认值处理)
return generateDesertRiver(mapSize, gobiHeight, stoneHeight, rand);
};
export const getGobiLayerType = (absoluteHeight: number, _stackHeight: number, _variant: number): VoxelType => {
if (absoluteHeight >= 5) {
return 'gobi_peak';
} else if (absoluteHeight === 4) {
return 'gobi_top';
} else if (absoluteHeight === 3) {
return 'gobi_upper';
} else if (absoluteHeight === 2) {
return 'gobi_lower';
} else {
return 'gobi_base';
}
};
export const getStoneLayerType = (_depthFromTop: number, variant: number): VoxelType => {
return variant % 2 === 0 ? 'stone' : 'dark_stone';
};

View File

@@ -0,0 +1,678 @@
import type { VoxelData } from './terrain';
import { rgbToHex } from './voxelStyles';
/**
* 新的植被生成系统
* 整合了多个生物群系的植被生成逻辑 (Desert, Forest, Ice, Swamp, Beach, Boreal)
* 分辨率: 32x32 (TREE_MICRO_SCALE)
*/
export interface VegetationGenerationContext {
mapSize: number;
voxels: VoxelData[];
occupancy: Set<string>;
treePositions: Array<{ x: number, z: number }>;
seed: number;
desertContext?: any;
isDesertScene: boolean;
sceneType?: string;
heightMap: Map<string, number>;
}
// =========================================
// --- 1. 统一颜色调色板 ---
// =========================================
const C: Record<string, string> = {
// Desert (Balanced greens)
gDarkest: '#24382A', gDark: '#3E5E48', gMid: '#628A52', gLight: '#8FB371',
gGrey: '#7A8F82', gLime: '#B5D178', gTeal: '#587878',
woodD: '#5C4A35', woodL: '#9E8C68',
sand: '#D6CFA3', red: '#D94141', pink: '#D97998', yellow: '#EBC944',
orange: '#F29F05', white: '#F0F0F0', rock: '#8A867D',
crystal: '#66CCFF', bone: '#E3E1CD', water: '#4FA4F4',
// Temperate Forest (Balanced greens)
leafDeep: '#3E6B42', leafMid: '#4E7A45', leafLight: '#6A9655',
pineD: '#385E4B', pineL: '#567D65',
willow: '#7E9E64', poplar: '#68994D',
woodOak: '#6B503B', woodBirch: '#E8E8E0', spot: '#3B3B3B',
dirt: '#755E49', grass: '#6FA648', stone: '#888C8D',
// Ice/Tundra (Balanced greens)
iceBase: '#E0FFFF', iceDeep: '#AEEEEE', iceSpike: '#F0FFFF',
snow: '#FFFFFF',
lichenOrange: '#D98E32', lichenRed: '#A64B4B', lichenGreen: '#6B855E',
frozenGreen: '#92C4B5', frozenStem: '#56757A',
crystalBlue: '#7FFFD4', crystalWhite: '#EFFFFF',
coralBlue: '#55DDFF',
pineDeepBoreal: '#305240', pineMidBoreal: '#456E56', pineLightBoreal: '#608A6E',
larchYellow: '#D9A036', frozenBlue: '#A3C9D9',
birchWhite: '#F0F0F2', birchSpot: '#2A2A2E', woodDark: '#4D3D30', woodRed: '#6B4E40',
rockGrey: '#6E7578', rockMoss: '#657A42',
mossLichen: '#9DAF7D', berryRed: '#C93636', berryBlue: '#364C85',
cottonWhite: '#FDFDFD', grassDry: '#8C8060',
// Swamp (Balanced greens)
leafDarkSwamp: '#354D40', leafMurky: '#4C6B58', leafLightSwamp: '#6D8C6E',
moss: '#6A8252', spanishMoss: '#95A399',
woodWet: '#4A4238', woodRot: '#63584C',
mud: '#3B3029', mudDry: '#54463A',
reedGreen: '#63783E', reedBrown: '#543D33',
lilypad: '#577A30', flowerLotus: '#D9AAB7',
mushroomCap: '#8A4B3A', mushroomGill: '#D6C29E',
fungusPurple: '#5D456B',
// Beach (Balanced greens)
palmGreen: '#68B028', palmDark: '#45851E',
bananaLeaf: '#7FB539', bananaStem: '#A3B35C',
trunkPalm: '#966838', trunkGrey: '#A89C8F', trunkDark: '#664A3C',
rootRed: '#96501E',
cactusGreen: '#62944B', cactusLight: '#86B56A', cactusFlower: '#FF7777',
flowerBird: '#FF8800', flowerPurple: '#9933CC',
cycadTrunk: '#54463A', cycadLeaf: '#3C9663',
coconut: '#664A3C'
};
const darkenHex = (hex: string, factor: number): string => {
if (!hex) return '#000000';
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return rgbToHex(r * factor, g * factor, b * factor);
};
// =========================================
// --- 2. Voxel Builder & Helpers ---
// =========================================
type AddBlockFn = (dx: number, dy: number, dz: number, color: string) => void;
class VoxelBuilder {
private addBlock: AddBlockFn;
constructor(addBlock: AddBlockFn) {
this.addBlock = addBlock;
}
add(x: number, y: number, z: number, hexColor: string) {
let darkFactor = 1.0;
// Simple AO / shading
if (y < 1) darkFactor = 0.85;
if (y < 0.5) darkFactor = 0.7;
const finalColor = darkFactor < 1.0 ? darkenHex(hexColor, darkFactor) : hexColor;
this.addBlock(Math.round(x), Math.round(y), Math.round(z), finalColor);
}
}
// Basic Shapes
const drawStem = (builder: VoxelBuilder, x: number, y: number, z: number, h: number, r: number, cBase: string, cHigh: string, isRoot: boolean = false) => {
let r2 = r * r;
let inR2 = (r - 0.9) ** 2;
for (let yi = 0; yi < h; yi++) {
let isCap = (yi === Math.floor(h) - 1);
for (let xi = -Math.ceil(r); xi <= Math.ceil(r); xi++) {
for (let zi = -Math.ceil(r); zi <= Math.ceil(r); zi++) {
let d2 = xi * xi + zi * zi;
if (d2 <= r2) {
if (!isCap && d2 < inR2) continue;
let col = (xi + zi) % 2 === 0 ? cHigh : cBase;
if (isRoot && yi < 2) col = C.woodL;
builder.add(x + xi, y + yi, z + zi, col);
}
}
}
}
};
const drawSphere = (builder: VoxelBuilder, ox: number, oy: number, oz: number, r: number, cBase: string, cVar: string, stretchY: number = 1, noise: number = 0, coreRatio: number = 0.0) => {
let r2 = r * r;
// Use coreRatio to allow solid cores (from qiulin.html)
// If coreRatio > 0, we fill the center.
// Default existing logic was hollow-ish or random.
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let y = -Math.ceil(r * stretchY); y <= Math.ceil(r * stretchY); y++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
let d2 = x * x + (y / stretchY) ** 2 + z * z;
let isCore = coreRatio > 0 && d2 < r2 * coreRatio;
let isEdge = d2 <= r2 + (Math.random() - 0.5) * noise * r2;
if (isCore || isEdge) {
builder.add(ox + x, oy + y, oz + z, Math.random() > 0.8 ? cVar : cBase);
}
}
}
}
};
const drawCylinder = (builder: VoxelBuilder, ox: number, oy: number, oz: number, h: number, r: number, col: string, isBirch: boolean = false) => {
let r2 = r * r;
for (let y = 0; y < h; y++) {
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
if (x * x + z * z <= r2) {
let c = col;
if (isBirch && (x * x + z * z) > (r2 * 0.6) && Math.random() > 0.85) c = C.birchSpot;
builder.add(ox + x, oy + y, oz + z, c);
}
}
}
}
};
// Advanced Shapes (from imports)
const drawCloudCluster = (builder: VoxelBuilder, ox: number, oy: number, oz: number, mainR: number, count: number, subRMin: number, subRMax: number, cBase: string, cVar: string) => {
// Center sphere
drawSphere(builder, ox, oy, oz, mainR, cBase, cVar, 0.9, 0.1, 0.9);
// Sub spheres
for (let i = 0; i < count; i++) {
let angPhi = Math.random() * Math.PI * 2;
let angTheta = Math.random() * Math.PI;
let dist = mainR * (0.5 + Math.random() * 0.4);
let cx = ox + Math.sin(angTheta) * Math.cos(angPhi) * dist;
let cy = oy + Math.cos(angTheta) * dist * 0.8;
let cz = oz + Math.sin(angTheta) * Math.sin(angPhi) * dist;
let r = subRMin + Math.random() * (subRMax - subRMin);
drawSphere(builder, Math.round(cx), Math.round(cy), Math.round(cz), r, cBase, cVar, 1, 0.3, 0.8);
}
};
const createMudIsland = (builder: VoxelBuilder, ox: number, oz: number, r: number) => {
let h = 2;
for (let y = 0; y < h; y++) {
let currentR = r * (1 - y / (h + 1));
for (let x = -Math.ceil(currentR); x <= Math.ceil(currentR); x++) {
for (let z = -Math.ceil(currentR); z <= Math.ceil(currentR); z++) {
if (x * x + z * z <= currentR * currentR) {
builder.add(ox + x, y, oz + z, Math.random() > 0.7 ? C.mudDry : C.mud);
}
}
}
}
};
// =========================================
// --- 3. Generators by Biome ---
// =========================================
// --- DESERT (Original + Expanded) ---
const createJointedSaguaroBase = (builder: VoxelBuilder, ox: number, oz: number, config: any) => {
const { h, r, armCount, armStyle } = config;
drawStem(builder, ox, 0, oz, h, r, C.gDark, C.gMid, true);
for (let xi = -Math.ceil(r); xi <= Math.ceil(r); xi++) {
for (let zi = -Math.ceil(r); zi <= Math.ceil(r); zi++) {
if (xi * xi + zi * zi <= r * r) builder.add(ox + xi, h, oz + zi, C.gDarkest);
}
}
for (let i = 0; i < armCount; i++) {
let angle = (Math.PI * 2 * i) / armCount + (armStyle === 'wild' ? Math.random() : 0);
let startY = h * (armStyle === 'low' ? 0.3 : armStyle === 'high' ? 0.7 : 0.5) + (Math.random() - 0.5) * 4;
let elbowLen = (armStyle === 'wild' ? 4 + Math.random() * 4 : 3 + Math.random() * 2);
let armH = (armStyle === 'goliath' ? 6 : h * 0.4 + Math.random() * 4);
let armR = r * (armStyle === 'goliath' ? 0.6 : 0.7);
let cos = Math.cos(angle), sin = Math.sin(angle);
for (let d = r - 0.5; d <= r + elbowLen; d += 0.5) {
let ex = ox + cos * d, ez = oz + sin * d;
drawStem(builder, ex, startY, ez, 1.5, armR * 0.9, C.woodL, C.woodL, false);
}
let tipX = ox + cos * (r + elbowLen), tipZ = oz + sin * (r + elbowLen);
drawStem(builder, tipX, startY, tipZ, armH, armR, C.gDark, C.gMid, false);
for (let xi = -Math.ceil(armR); xi <= Math.ceil(armR); xi++) {
for (let zi = -Math.ceil(armR); zi <= Math.ceil(armR); zi++) {
if (xi * xi + zi * zi <= armR * armR) builder.add(tipX + xi, startY + armH, tipZ + zi, C.gDarkest);
}
}
}
};
const createJointedClassic = (b: VoxelBuilder, x: number, z: number) => createJointedSaguaroBase(b, x, z, { h: 24, r: 2.2, armCount: 2, armStyle: 'balanced' });
const createJointedCandelabra = (b: VoxelBuilder, x: number, z: number) => createJointedSaguaroBase(b, x, z, { h: 28, r: 3, armCount: 4, armStyle: 'low' });
const createSaguaro = (b: VoxelBuilder, ox: number, oz: number) => {
drawStem(b, ox, 0, oz, 24, 2, C.gDark, C.gMid, true);
drawSphere(b, ox, 24, oz, 2, C.gDark, C.gMid, 0.6);
};
// (Omitting some minor desert variations for brevity, keeping the impressive ones)
// --- TEMPERATE FOREST (Qiulin) ---
const createLushOak = (builder: VoxelBuilder, ox: number, oz: number) => {
drawCylinder(builder, ox, 0, oz, 7, 2, C.woodOak);
drawSphere(builder, ox, 0, oz, 4, C.dirt, C.grass, 0.4);
drawCloudCluster(builder, ox, 8, oz, 4.5, 12, 2.5, 4.0, C.leafDeep, C.leafLight);
};
const createFullBirch = (builder: VoxelBuilder, ox: number, oz: number) => {
let h = 14;
let lean = (Math.random() - 0.5) * 2;
for (let y = 0; y < h; y++) {
let dx = Math.round(lean * (y / h));
builder.add(ox + dx, y, oz, Math.random() > 0.85 ? C.spot : C.woodBirch);
if (y > 1 && y < 4) {
builder.add(ox + dx + 1, y, oz, C.woodBirch);
builder.add(ox + dx, y, oz + 1, C.woodBirch);
}
}
drawSphere(builder, ox + Math.round(lean), h - 1, oz, 4.5, C.poplar, C.leafLight, 1.2, 0.2, 0.9);
};
const createDensePine = (builder: VoxelBuilder, ox: number, oz: number) => {
drawCylinder(builder, ox, 0, oz, 4, 1.5, C.woodOak);
let startY = 3;
let layers = 4;
for (let i = 0; i < layers; i++) {
let y = startY + i * 3.5;
let bottomR = 5.5 - i * 1.2;
let topR = 3.5 - i * 0.8;
let height = 4;
for (let ly = 0; ly < height; ly++) {
let r = bottomR + (topR - bottomR) * (ly / height);
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
if (x * x + z * z <= r * r) {
if (x * x + z * z < r * r * 0.8 || Math.random() > 0.3)
builder.add(ox + x, Math.round(y + ly), oz + z, C.pineD);
}
}
}
}
}
drawCylinder(builder, ox, startY + layers * 3.5, oz, 3, 1, C.pineD);
builder.add(ox, startY + layers * 3.5 + 3, oz, C.pineL);
};
const createCurtainWillow = (builder: VoxelBuilder, ox: number, oz: number) => {
drawCylinder(builder, ox, 0, oz, 5, 1.5, C.woodOak);
drawSphere(builder, ox, 6, oz, 5, C.willow, C.leafLight, 0.7, 0.1, 0.95);
for (let i = 0; i < 20; i++) {
let ang = (i / 20) * Math.PI * 2;
let dist = 3.5 + Math.random();
let sx = ox + Math.cos(ang) * dist;
let sz = oz + Math.sin(ang) * dist;
let len = 4 + Math.random() * 5;
for (let y = 0; y < len; y++) {
let px = Math.round(sx + (Math.random() - 0.5) * 0.5);
let pz = Math.round(sz + (Math.random() - 0.5) * 0.5);
let py = 6 - y;
if (py < 0) continue;
builder.add(px, py, pz, C.willow);
if (Math.random() > 0.6) builder.add(px, py, pz + 1, C.willow);
}
}
};
const createFatPoplar = (builder: VoxelBuilder, ox: number, oz: number) => {
drawCylinder(builder, ox, 0, oz, 3, 1, C.woodOak);
let h = 16;
for (let y = 0; y < h; y++) {
let r = 2.2 * Math.sin((y / h) * Math.PI);
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
if (x * x + z * z <= r * r) {
builder.add(ox + x, y + 2, oz + z, C.poplar);
}
}
}
}
};
// --- SWAMP (Zaoze) ---
const createCypress = (builder: VoxelBuilder, ox: number, oz: number) => {
createMudIsland(builder, ox, oz, 4.5);
let h = 16;
for (let y = 0; y < 8; y++) {
let r = 4.5 * Math.pow(0.7, y);
if (r < 1.5) r = 1.5;
drawCylinder(builder, ox, y + 1, oz, 1, r, C.woodWet);
}
drawCylinder(builder, ox, 9, oz, 8, 1.5, C.woodWet);
let crownY = 16;
drawSphere(builder, ox, crownY, oz, 5, C.leafDarkSwamp, C.leafMurky, 0.6, 0.3, 0.7);
for (let i = 0; i < 12; i++) {
let ang = Math.random() * Math.PI * 2;
let dist = 3 + Math.random() * 2;
let x = Math.round(ox + Math.cos(ang) * dist);
let z = Math.round(oz + Math.sin(ang) * dist);
let len = 4 + Math.random() * 5;
for (let y = 0; y < len; y++) {
builder.add(x, crownY - 1 - y, z, C.spanishMoss);
}
}
// Knees
for (let i = 0; i < 5; i++) {
let ang = Math.random() * Math.PI * 2;
let dist = 5 + Math.random() * 3;
let kx = Math.round(ox + Math.cos(ang) * dist);
let kz = Math.round(oz + Math.sin(ang) * dist);
let kh = 1 + Math.random() * 2;
builder.add(kx, 0, kz, C.woodWet);
for (let ky = 0; ky < kh; ky++) builder.add(kx, 1 + ky, kz, C.woodWet);
}
};
const createSwampWillow = (builder: VoxelBuilder, ox: number, oz: number) => {
createMudIsland(builder, ox, oz, 3);
drawCylinder(builder, ox, 1, oz, 6, 1.5, C.woodRot);
drawSphere(builder, ox, 7, oz, 4, C.leafMurky, C.spanishMoss, 0.8, 0.2, 0.7);
for (let i = 0; i < 20; i++) {
let ang = (i / 20) * Math.PI * 2;
let dist = 3.5;
let x = Math.round(ox + Math.cos(ang) * dist);
let z = Math.round(oz + Math.sin(ang) * dist);
for (let y = 7; y >= 1; y--) {
if (Math.random() > 0.2) builder.add(x, y, z, C.leafDarkSwamp);
}
}
};
const createGiantMushroom = (builder: VoxelBuilder, ox: number, oz: number) => {
createMudIsland(builder, ox, oz, 1.5);
for (let y = 1; y < 6; y++) builder.add(ox, y, oz, C.woodRot);
let r = 3;
for (let x = -r; x <= r; x++) {
for (let z = -r; z <= r; z++) {
if (x * x + z * z <= r * r) {
builder.add(ox + x, 6, oz + z, C.mushroomGill);
builder.add(ox + x, 7, oz + z, C.mushroomCap);
if (Math.random() > 0.8) builder.add(ox + x, 8, oz + z, C.mushroomCap);
}
}
}
};
// --- BOREAL / TUNDRA (Handai) ---
const createSpruceGeneral = (builder: VoxelBuilder, ox: number, oz: number, scale: number, snowFactor: number) => {
let rootH = Math.max(2, Math.floor(4 * scale));
drawCylinder(builder, ox, 0, oz, rootH, 1.5 * scale, C.woodDark);
let layers = Math.max(3, Math.floor(7 * scale));
let currentY = rootH - 1;
let radius = 5 * scale;
for (let i = 0; i < layers; i++) {
let layerH = Math.max(2, Math.floor(3 * scale));
for (let y = 0; y < layerH; y++) {
let r = radius * (1 - y / (layerH + 0.5));
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
if (x * x + z * z <= r * r) {
let col = Math.random() > 0.7 ? C.pineMidBoreal : C.pineDeepBoreal;
if (snowFactor > 0) {
if (y == layerH - 1 || (x * x + z * z > r * r * 0.7)) if (Math.random() < snowFactor) col = C.snow;
}
builder.add(ox + x, currentY + y, oz + z, col);
}
}
}
}
currentY += (layerH - 1);
radius -= (0.6 * scale);
}
let topCol = snowFactor > 0.5 ? C.snow : C.pineLightBoreal;
builder.add(ox, currentY, oz, topCol);
builder.add(ox, currentY + 1, oz, topCol);
};
const createSpruceTallSnowy = (b: VoxelBuilder, x: number, z: number) => createSpruceGeneral(b, x, z, 1.0, 0.7);
const createSpruceMediumClear = (b: VoxelBuilder, x: number, z: number) => createSpruceGeneral(b, x, z, 0.7, 0.0);
const createLarch = (builder: VoxelBuilder, ox: number, oz: number) => {
drawCylinder(builder, ox, 0, oz, 18, 1.2, C.woodRed);
let sy = 6;
for (let i = 0; i < 20; i++) {
let y = sy + Math.random() * 12, a = Math.random() * 6.28, l = (18 - y) * 0.3 + 1;
let ex = ox + Math.cos(a) * l, ez = oz + Math.sin(a) * l;
builder.add(Math.round(ex), Math.round(y), Math.round(ez), C.larchYellow);
builder.add(Math.round(ex), Math.round(y) + 1, Math.round(ez), C.snow);
}
for (let y = 0; y < 3; y++) builder.add(ox, 18 + y, oz, C.larchYellow);
};
// --- ICEBERG (Iceland) ---
const createCoralTree = (builder: VoxelBuilder, ox: number, oz: number) => {
const branch = (sx: number, sy: number, sz: number, len: number, dirX: number, dirZ: number, width: number) => {
if (len < 2) return;
let steps = Math.floor(len);
let cx = sx, cy = sy, cz = sz;
for (let i = 0; i < steps; i++) {
let w = Math.ceil(width);
for (let wx = -Math.floor(w / 2); wx < Math.ceil(w / 2); wx++) {
for (let wz = -Math.floor(w / 2); wz < Math.ceil(w / 2); wz++) {
let col = cy < 6 ? C.coralBlue : C.iceSpike;
if (Math.random() > 0.9) col = C.crystalWhite;
builder.add(Math.round(cx + wx), Math.round(cy), Math.round(cz + wz), col);
}
}
cy += 1; cx += dirX * 0.5; cz += dirZ * 0.5;
}
if (width > 0.5) {
branch(cx, cy, cz, len * 0.7, dirX + 0.5, dirZ + 0.5, width * 0.6);
branch(cx, cy, cz, len * 0.7, dirX - 0.5, dirZ - 0.5, width * 0.6);
}
};
builder.add(ox, 0, oz, C.coralBlue);
branch(ox, 0, oz, 8, 0, 0, 2);
};
const createIceSpike = (builder: VoxelBuilder, ox: number, oz: number) => {
let h = 4 + Math.random() * 4;
for (let y = 0; y < h; y++) {
let r = (h - y) * 0.4;
for (let x = -Math.ceil(r); x <= Math.ceil(r); x++) {
for (let z = -Math.ceil(r); z <= Math.ceil(r); z++) {
if (x * x + z * z <= r * r) builder.add(ox + x, y, oz + z, C.iceSpike);
}
}
}
};
// --- BEACH (Tropical) ---
const createPalm = (builder: VoxelBuilder, ox: number, oz: number, height: number, leanType: 'straight' | 'slight') => {
let leanX = 0, leanZ = 0;
if (leanType === 'straight') {
leanX = (Math.random() - 0.5) * 2; leanZ = (Math.random() - 0.5) * 2;
} else {
let ang = Math.random() * 6.28; leanX = Math.cos(ang) * 5; leanZ = Math.sin(ang) * 5;
}
let cx = ox, cz = oz;
for (let y = 0; y < height; y++) {
cx += leanX / height; cz += leanZ / height;
let col = (y % 2 == 0) ? C.trunkPalm : C.trunkDark;
builder.add(Math.round(cx), y, Math.round(cz), col);
if (y < height - 2) {
builder.add(Math.round(cx) + 1, y, Math.round(cz), col);
builder.add(Math.round(cx), y, Math.round(cz) + 1, col);
}
}
// Coconuts
for (let i = 0; i < 4; i++) builder.add(Math.round(cx + (Math.random() - 0.5) * 2), height - 1, Math.round(cz + (Math.random() - 0.5) * 2), C.coconut);
// Fronds
let fronds = 12;
for (let i = 0; i < fronds; i++) {
let a = i / fronds * 6.28;
let len = 8 + Math.random() * 4;
for (let d = 0; d < len; d++) {
let fx = cx + Math.cos(a) * d;
let fz = cz + Math.sin(a) * d;
let fy = height + 2 - d * d * 0.06;
if (d > 1) {
builder.add(Math.round(fx), Math.round(fy), Math.round(fz), C.palmGreen);
builder.add(Math.round(fx), Math.round(fy), Math.round(fz) + 1, C.palmGreen);
}
}
}
};
const createTwinPalms = (b: VoxelBuilder, x: number, z: number) => {
drawCylinder(b, x, 0, z, 2, 2.5, C.trunkPalm);
createPalm(b, x - 1, z, 14, 'slight');
createPalm(b, x + 1, z, 22, 'slight');
};
const createMangrove = (builder: VoxelBuilder, ox: number, oz: number) => {
let rootH = 6;
for (let i = 0; i < 8; i++) {
let ang = (i / 8) * Math.PI * 2;
let dist = 3.5;
for (let h = 0; h < rootH; h++) {
let t = h / rootH;
let lx = Math.round(ox + Math.cos(ang) * dist * (1 - t));
let lz = Math.round(oz + Math.sin(ang) * dist * (1 - t));
builder.add(lx, h, lz, C.rootRed);
builder.add(lx + 1, h, lz, C.rootRed);
builder.add(lx, h, lz + 1, C.rootRed);
}
}
drawCylinder(builder, ox, rootH, oz, 4, 2, C.rootRed);
drawSphere(builder, ox, rootH + 4, oz, 5.5, C.palmDark, C.palmGreen, 0.8, 0.3, 0.8);
};
const createBanana = (builder: VoxelBuilder, ox: number, oz: number) => {
let h = 8;
drawCylinder(builder, ox, 0, oz, h, 1, C.bananaStem);
let leaves = 5;
for (let i = 0; i < leaves; i++) {
let ang = i / leaves * 6.28;
let len = 7;
for (let d = 0; d < len; d++) {
let lx = ox + Math.cos(ang) * d;
let lz = oz + Math.sin(ang) * d;
let ly = h + d * 0.5;
if (d > len / 2) ly = h + len * 0.5 - (d - len / 2);
let w = 3;
for (let k = -Math.floor(w / 2); k <= Math.floor(w / 2); k++) {
let px = lx - Math.sin(ang) * k;
let pz = lz + Math.cos(ang) * k;
builder.add(Math.round(px), Math.round(ly), Math.round(pz), C.bananaLeaf);
}
}
}
};
// =========================================
// --- 4. Generator Registration ---
// =========================================
const DESERT_GENERATORS = [createJointedClassic, createJointedCandelabra, createSaguaro];
const FOREST_GENERATORS = [createLushOak, createFullBirch, createDensePine, createCurtainWillow, createFatPoplar];
const SWAMP_GENERATORS = [createCypress, createSwampWillow, createGiantMushroom];
const TUNDRA_GENERATORS = [createSpruceTallSnowy, createSpruceMediumClear, createLarch];
const ICE_GENERATORS = [createCoralTree, createIceSpike];
const BEACH_GENERATORS = [createTwinPalms, createMangrove, createBanana, (b: any, x: any, z: any) => createPalm(b, x, z, 20, 'straight')];
// =========================================
// --- 5. Main Generation Logic ---
// =========================================
export const generateVegetation = async (
context: VegetationGenerationContext
): Promise<VoxelData[]> => {
const { mapSize, occupancy, sceneType, desertContext, heightMap } = context;
const newVoxels: VoxelData[] = [];
const TILE_SIZE = 1;
const TREE_VOXEL_SIZE = TILE_SIZE / 32;
const addVoxel = (ix: number, iy: number, iz: number, color: string) => {
const key = `tree_${ix}|${iy}|${iz}`;
if (occupancy.has(key)) return;
newVoxels.push({
x: ix * TREE_VOXEL_SIZE,
y: iy * TREE_VOXEL_SIZE,
z: iz * TREE_VOXEL_SIZE,
type: 'leaves', // Generic type, color dictates look
color: color,
ix, iy, iz,
heightScale: 1,
isHighRes: true
});
occupancy.add(key);
};
const builder = new VoxelBuilder(addVoxel);
let generators: any[] = [];
let density = 0.05;
// Determine generators and density based on scene
// Map legacy names if necessary
switch (sceneType) {
case 'desert':
generators = DESERT_GENERATORS;
density = 0.05; // Sparse
break;
case 'mountain': // Mapping Qiulin/Temperate to Mountain/Plains
case 'plains':
generators = FOREST_GENERATORS;
density = 0.15; // Dense forest
break;
case 'swamp':
generators = SWAMP_GENERATORS;
density = 0.2; // Very dense
break;
case 'tundra':
case 'snowy_mountain':
generators = [...TUNDRA_GENERATORS, ...ICE_GENERATORS];
density = 0.1;
break;
case 'beach':
case 'riverside':
generators = BEACH_GENERATORS;
density = 0.12;
break;
default:
// Fallback to forest for unknown types, or empty
generators = FOREST_GENERATORS;
density = 0.1;
break;
}
console.log(`Generating vegetation for scene: ${sceneType}, density: ${density}`);
for (let lx = 0; lx < mapSize; lx++) {
for (let lz = 0; lz < mapSize; lz++) {
// Check valid placement
// For desert: check stone/gobi/stream
// For others: check if surface is appropriate (water/stone checks)
// Retrieve context data
const stoneHeight = desertContext?.stoneHeight[lx]?.[lz] ?? 0;
const gobiHeight = desertContext?.gobiHeight[lx]?.[lz] ?? 0;
const centerMicroX = lx * 8 + 4;
const centerMicroZ = lz * 8 + 4;
const streamInfo = desertContext?.streamDepthMap.get(`${centerMicroX}|${centerMicroZ}`);
const isStream = (streamInfo?.depth ?? 0) > 0;
// General exclusion rules
if (stoneHeight > 0 && sceneType !== 'tundra' && sceneType !== 'snowy_mountain') continue; // Allow trees on rocks in tundra sometimes
if (isStream && sceneType !== 'swamp') continue; // Swamp trees grow in water
// Desert specific exclusion
if (sceneType === 'desert' && gobiHeight > 0) continue; // Avoid gobi tops
// Get surface height
const terrainX = lx * 4 + 2; // Rough center in 32-grid (8*4=32)
const terrainZ = lz * 4 + 2;
// Map terrain 8x8 to logic... wait heightMap is 8x8 keys?
// heightMap keys are "ix|iz" where ix is 0..mapSize*8
// We need to find the surface Y at this tile center
const hKey = `${lx*8+4}|${lz*8+4}`;
const terrainY = heightMap.get(hKey);
if (terrainY === undefined) continue;
// Convert to tree-grid height (32x32)
// 1 terrain unit (8 micro) = 4 tree units (32 micro)
// surfaceY in tree grid
let surfaceY = (terrainY + 1) * 4;
// Swamp special: trees can sink a bit or grow on mud
if (sceneType === 'swamp') surfaceY -= 2;
// Micro coordinates for tree grid (center of tile)
const microX = lx * 32 + 16;
const microZ = lz * 32 + 16;
if (Math.random() < density) {
const gen = generators[Math.floor(Math.random() * generators.length)];
// Inject offset into builder
const originalAdd = builder['addBlock'];
builder['addBlock'] = (dx, dy, dz, color) => {
addVoxel(microX + dx, surfaceY + dy, microZ + dz, color);
};
gen(builder, 0, 0);
builder['addBlock'] = originalAdd;
}
}
}
return newVoxels;
};

View File

@@ -0,0 +1,790 @@
/**
* 地形后处理函数
* 包含各种对生成的地形进行后处理的算法
*/
// ==================== 类型定义 ====================
/**
* VoxelType 类型定义从terrain.ts复制保持一致
*/
export type VoxelType =
| 'water'
| 'sand'
| 'grass'
| 'stone'
| 'snow'
| 'dirt'
| 'wood'
| 'leaves'
| 'dark_stone'
| 'bedrock'
| 'grass_dirt_blend'
| 'dark_grass'
| 'cactus'
| 'deep_dirt'
| 'dark_grass_ground'
| 'medium_dirt'
| 'flower'
| 'reed'
| 'lava'
| 'volcanic_rock'
| 'obsidian'
| 'ash'
| 'magma_stone'
| 'mud'
| 'murky_water'
| 'swamp_grass'
| 'moss'
| 'lily_pad'
| 'ice'
| 'packed_ice'
| 'frozen_stone'
| 'icicle'
| 'permafrost'
| 'gobi_base'
| 'gobi_lower'
| 'gobi_upper'
| 'gobi_top'
| 'gobi_peak'
| 'etched_sand';
// ==================== 工具函数 ====================
/**
* 伪随机数生成器从terrain.ts复制
*/
function pseudoRandom(seed: number): number {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
/**
* clamp 函数从terrain.ts复制
*/
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
interface DesertContext {
gobiHeight: number[][];
gobiVariant: number[][];
gobiMinHeight: number[][];
stoneHeight: number[][];
stoneVariant: number[][];
streamDepthMap: Map<string, { depth: number; waterHeight: number }>;
}
// ==================== 戈壁风化算法(基于迭代侵蚀)====================
/**
* 戈壁风化预处理结果
* 包含应该被移除的体素集合
*/
export interface GobiWeatheringResult {
// 使用 Set 存储应该被移除的体素坐标 "ix|iy|iz"
removedVoxels: Set<string>;
}
/**
* 戈壁和巨石风化预处理参数
*/
export interface GobiWeatheringPreprocessParams {
desertContext: DesertContext; // 沙漠上下文
mapSize: number; // 地图大小(逻辑方块数)
MICRO_SCALE: number; // 微观缩放比例
gobiMaxIterations: number; // 戈壁最大风蚀层数,默认 3
gobiHeightBias: number; // 戈壁高度保护阈值,默认 0.5
stoneMaxIterations: number; // 巨石最大风蚀层数,默认 2
stoneHeightBias: number; // 巨石高度保护阈值,默认 0.6
stoneMinExposedFaces: number; // 巨石最少暴露面数,默认 3
MIN_WORLD_Y: number; // 最小世界Y坐标
}
/**
* 戈壁风化预处理:生成整个地图的戈壁风化结果
* 基于迭代侵蚀算法,识别暴露的边缘并逐层移除
*
* @param params 风化预处理参数
* @returns 风化结果,包含应该被移除的体素集合
*/
export function preprocessGobiWeathering(params: GobiWeatheringPreprocessParams): GobiWeatheringResult {
const {
desertContext,
mapSize,
MICRO_SCALE,
gobiMaxIterations,
gobiHeightBias,
stoneMaxIterations,
stoneHeightBias,
stoneMinExposedFaces,
MIN_WORLD_Y,
} = params;
// 计算微观网格大小
const microMapSize = mapSize * MICRO_SCALE;
// 预估最大世界Y坐标关键修复基于真实的 worldY 计算)
let maxWorldY = MIN_WORLD_Y;
for (let lx = 0; lx < mapSize; lx++) {
for (let lz = 0; lz < mapSize; lz++) {
const gobiMicroHeight = desertContext.gobiHeight[lx][lz] * MICRO_SCALE;
const stoneMicroHeight = desertContext.stoneHeight[lx][lz] * MICRO_SCALE;
const logicHeight = 2; // 沙漠场景的基础高度
// 计算该位置的最大可能 worldY表面高度
const surfaceY = logicHeight * MICRO_SCALE + stoneMicroHeight + gobiMicroHeight;
maxWorldY = Math.max(maxWorldY, surfaceY);
}
}
// 基于 maxWorldY 计算网格高度(相对于 MIN_WORLD_Y
const gridHeight = Math.ceil(maxWorldY - MIN_WORLD_Y + MICRO_SCALE * 2);
// 创建3D网格包含戈壁和巨石体素
// 使用 Uint8Array 节省内存0 = 空1 = 戈壁体素2 = 巨石体素
const voxelGrid = new Uint8Array(microMapSize * gridHeight * microMapSize);
// 获取网格索引的辅助函数
const getIdx = (ix: number, iy: number, iz: number): number => {
return ix + iy * microMapSize + iz * microMapSize * gridHeight;
};
// 检查坐标是否在范围内
const inBounds = (ix: number, iy: number, iz: number): boolean => {
return ix >= 0 && ix < microMapSize &&
iy >= 0 && iy < gridHeight &&
iz >= 0 && iz < microMapSize;
};
// 第一步:填充戈壁体素到网格中
// 关键:我们需要为每个 (ix, iz) 计算真实的 surfaceY然后用真实的世界Y坐标
for (let lx = 0; lx < mapSize; lx++) {
for (let lz = 0; lz < mapSize; lz++) {
const gobiLogicHeight = desertContext.gobiHeight[lx][lz];
const gobiMicroHeight = gobiLogicHeight * MICRO_SCALE;
const stoneMicroHeight = desertContext.stoneHeight[lx][lz] * MICRO_SCALE;
const minLayer = desertContext.gobiMinHeight[lx][lz] || 0;
// 只处理有戈壁的区域
if (gobiMicroHeight === 0) continue;
// 计算这个逻辑方块的基础高度(需要复制 terrain.ts 的逻辑)
// 注意:这里假设基础地形高度是固定的(沙漠场景通常是平坦的)
// 对于沙漠场景logicHeight 通常是固定值
const logicHeight = 2; // 沙漠场景的基础高度(从 terrain.ts 看出)
// 遍历该逻辑方块对应的所有微观方块
for (let mx = 0; mx < MICRO_SCALE; mx++) {
for (let mz = 0; mz < MICRO_SCALE; mz++) {
const ix = lx * MICRO_SCALE + mx;
const iz = lz * MICRO_SCALE + mz;
// 计算这个微观位置的 surfaceY复制 terrain.ts 的逻辑)
let surfaceY = logicHeight * MICRO_SCALE + stoneMicroHeight + gobiMicroHeight;
// 遍历高度,填充戈壁体素
for (let h = 0; h < gobiMicroHeight; h++) {
const microDepthFromBottom = h;
const layerFromBottom = Math.floor(microDepthFromBottom / MICRO_SCALE) + 1;
// 检查拱门空洞:如果当前层级低于最小高度限制,跳过
if (layerFromBottom <= minLayer) {
continue;
}
// 计算这个体素的真实世界Y坐标
// 戈壁体素从 surfaceY - stoneMicroHeight - gobiMicroHeight 开始向上堆叠
// depth = stoneMicroHeight + (gobiMicroHeight - 1 - h)
const depth = stoneMicroHeight + (gobiMicroHeight - 1 - microDepthFromBottom);
const worldY = surfaceY - depth;
// 转换为网格坐标(相对于 MIN_WORLD_Y
const gridY = worldY - MIN_WORLD_Y;
if (inBounds(ix, gridY, iz)) {
const idx = getIdx(ix, gridY, iz);
voxelGrid[idx] = 1; // 标记为戈壁体素
}
}
}
}
}
}
// 第一步(续):填充巨石体素到网格中
for (let lx = 0; lx < mapSize; lx++) {
for (let lz = 0; lz < mapSize; lz++) {
const stoneMicroHeight = desertContext.stoneHeight[lx][lz] * MICRO_SCALE;
// 只处理有巨石的区域
if (stoneMicroHeight === 0) continue;
// 计算基础高度
const logicHeight = 2; // 沙漠场景的基础高度
// 遍历该逻辑方块对应的所有微观方块
for (let mx = 0; mx < MICRO_SCALE; mx++) {
for (let mz = 0; mz < MICRO_SCALE; mz++) {
const ix = lx * MICRO_SCALE + mx;
const iz = lz * MICRO_SCALE + mz;
// 获取当前位置的戈壁高度可能为0
const gobiMicroHeight = desertContext.gobiHeight[lx][lz] * MICRO_SCALE;
// 计算这个微观位置的 surfaceY
let surfaceY = logicHeight * MICRO_SCALE + stoneMicroHeight + gobiMicroHeight;
// 遍历高度,填充巨石体素
for (let h = 0; h < stoneMicroHeight; h++) {
// 计算这个体素的真实世界Y坐标
// 修复:巨石位于最顶层 (surfaceY),向下延伸
// h 从 0 (底部) 到 stoneMicroHeight-1 (顶部)
// worldY 范围应为 [surfaceY - stoneMicroHeight + 1, surfaceY]
const worldY = surfaceY - stoneMicroHeight + 1 + h;
// 转换为网格坐标(相对于 MIN_WORLD_Y
const gridY = worldY - MIN_WORLD_Y;
if (inBounds(ix, gridY, iz)) {
const idx = getIdx(ix, gridY, iz);
voxelGrid[idx] = 2; // 标记为巨石体素
}
}
}
}
}
}
// 第二步:应用迭代风化算法
let currentGrid = new Uint8Array(voxelGrid);
const nextGrid = new Uint8Array(voxelGrid.length);
// 使用戈壁和巨石的最大迭代次数中的较大者
const maxIterations = Math.max(gobiMaxIterations, stoneMaxIterations);
for (let iter = 0; iter < maxIterations; iter++) {
// 复制当前网格到下一个网格(保留未被侵蚀的体素)
nextGrid.set(currentGrid);
// 遍历所有体素,检测并移除边缘
// 扩大到整个地图范围(包括边界),让边缘体素也能被风化
for (let ix = 0; ix < microMapSize; ix++) {
for (let gridY = 1; gridY < gridHeight; gridY++) { // 包含最顶层
for (let iz = 0; iz < microMapSize; iz++) {
const idx = getIdx(ix, gridY, iz);
// 只处理存在的体素
if (currentGrid[idx] === 0) continue;
// 获取体素类型1 = 戈壁2 = 巨石
const voxelType = currentGrid[idx];
const isGobi = voxelType === 1;
const isStone = voxelType === 2;
// 根据体素类型判断是否应该在当前迭代处理
// 戈壁:处理前 gobiMaxIterations 次迭代
// 巨石:处理前 stoneMaxIterations 次迭代
if (isGobi && iter >= gobiMaxIterations) continue;
if (isStone && iter >= stoneMaxIterations) continue;
// 计算暴露面数量6个方向的邻居
// 边界外视为空(暴露)
let exposedFaces = 0;
// X+ 方向
if (ix + 1 >= microMapSize || currentGrid[getIdx(ix + 1, gridY, iz)] === 0) exposedFaces++;
// X- 方向
if (ix - 1 < 0 || currentGrid[getIdx(ix - 1, gridY, iz)] === 0) exposedFaces++;
// Y+ 方向(顶部)
if (gridY + 1 >= gridHeight || currentGrid[getIdx(ix, gridY + 1, iz)] === 0) exposedFaces++;
// Y- 方向(底部)
if (gridY - 1 < 0 || currentGrid[getIdx(ix, gridY - 1, iz)] === 0) exposedFaces++;
// Z+ 方向
if (iz + 1 >= microMapSize || currentGrid[getIdx(ix, gridY, iz + 1)] === 0) exposedFaces++;
// Z- 方向
if (iz - 1 < 0 || currentGrid[getIdx(ix, gridY, iz - 1)] === 0) exposedFaces++;
// 根据体素类型定义"直角边"的暴露面要求
// 戈壁2个或以上暴露面棱或角
// 巨石3个或以上暴露面更严格只有更尖锐的角才被磨圆
const minExposedFaces = isStone ? stoneMinExposedFaces : 2;
const isSharpEdge = exposedFaces >= minExposedFaces;
if (!isSharpEdge) continue;
// 计算相对高度0.0 ~ 1.0
// 将 gridY 转换为真实世界高度
const worldY = MIN_WORLD_Y + gridY;
const hRatio = worldY / Math.max(1, maxWorldY);
// 额外保护:计算该体素下方是否有支撑
// 如果下方没有体素,不移除(避免破坏地基)
const hasBottomSupport = (gridY === 0) || (gridY > 0 && currentGrid[getIdx(ix, gridY - 1, iz)] > 0);
// [关键修复] 拱门和悬挑结构保护
// 检查上方是否有体素(说明自己是支撑柱的一部分)
// 如果上方有体素,且下方是空的(或者自己是底层),则说明自己是关键支撑点,绝对不能移除
const hasTopLoad = gridY + 1 < gridHeight && currentGrid[getIdx(ix, gridY + 1, iz)] > 0;
const isCriticalSupport = hasTopLoad && (!hasBottomSupport || gridY === 0);
if (isCriticalSupport) continue; // 绝对保护关键支撑点
// 根据体素类型选择高度保护阈值
const heightBias = isStone ? stoneHeightBias : gobiHeightBias;
let shouldRemove = false;
if (iter === 0) {
// [规则 1] 第一刀:保护底部,避免破坏支撑结构
// 底部40%高度强制保护,确保山丘有稳固的底座
if (hRatio > 0.4 && hasBottomSupport) {
shouldRemove = true;
}
} else {
// [规则 2] 后续刀:看高度阈值
// 高度超过阈值,且依然是直角边,继续移除
// 保护底部30%,避免过度侵蚀
if (hRatio > Math.max(0.3, heightBias) && hasBottomSupport) {
shouldRemove = true;
}
}
if (shouldRemove) {
nextGrid[idx] = 0; // 移除该体素
}
}
}
}
// 同步状态
currentGrid.set(nextGrid);
}
// 第三步:移除悬空体素(使用连通性检查)
// 找到所有与山丘主体连接的体素,移除孤立的体素
const connected = new Uint8Array(currentGrid.length); // 0 = 未访问, 1 = 已连接
// BFS 队列
const queue: Array<{ ix: number; gridY: number; iz: number }> = [];
// 关键修复:找到所有"底部暴露"的体素作为BFS起点
// 底部暴露 = 该体素存在且下方为空(接触地面或空气)
for (let ix = 0; ix < microMapSize; ix++) {
for (let gridY = 0; gridY < gridHeight; gridY++) {
for (let iz = 0; iz < microMapSize; iz++) {
const idx = getIdx(ix, gridY, iz);
// 如果该体素存在(戈壁或巨石)
if (currentGrid[idx] > 0) {
// 检查下方是否为空(底部暴露)
let isBottomExposed = false;
if (gridY === 0) {
// 最底层,视为暴露
isBottomExposed = true;
} else {
const belowIdx = getIdx(ix, gridY - 1, iz);
if (currentGrid[belowIdx] === 0) {
// 下方为空,底部暴露
isBottomExposed = true;
}
}
// 如果是底部暴露的体素加入BFS起点
if (isBottomExposed) {
connected[idx] = 1;
queue.push({ ix, gridY, iz });
}
}
}
}
}
// BFS从底部暴露的体素向上和周围传播标记所有连接的体素
while (queue.length > 0) {
const { ix, gridY, iz } = queue.shift()!;
// 检查 6 个相邻方向
const neighbors = [
{ dx: 1, dy: 0, dz: 0 }, // X+
{ dx: -1, dy: 0, dz: 0 }, // X-
{ dx: 0, dy: 1, dz: 0 }, // Y+ (向上)
{ dx: 0, dy: -1, dz: 0 }, // Y- (向下)
{ dx: 0, dy: 0, dz: 1 }, // Z+
{ dx: 0, dy: 0, dz: -1 }, // Z-
];
for (const { dx, dy, dz } of neighbors) {
const nx = ix + dx;
const ny = gridY + dy;
const nz = iz + dz;
// 检查是否在范围内
if (nx >= 0 && nx < microMapSize &&
ny >= 0 && ny < gridHeight &&
nz >= 0 && nz < microMapSize) {
const nIdx = getIdx(nx, ny, nz);
// 如果邻居存在(戈壁或巨石)且未访问过
if (currentGrid[nIdx] > 0 && connected[nIdx] === 0) {
connected[nIdx] = 1;
queue.push({ ix: nx, gridY: ny, iz: nz });
}
}
}
}
// 移除所有未连接的体素(悬空体素)
for (let idx = 0; idx < currentGrid.length; idx++) {
if (currentGrid[idx] > 0 && connected[idx] === 0) {
currentGrid[idx] = 0; // 移除悬空体素
}
}
// 第四步:收集被移除的体素(包括戈壁和巨石)
const removedVoxels = new Set<string>();
for (let ix = 0; ix < microMapSize; ix++) {
for (let gridY = 0; gridY < gridHeight; gridY++) {
for (let iz = 0; iz < microMapSize; iz++) {
const idx = getIdx(ix, gridY, iz);
// 原本存在但现在被移除的体素(戈壁或巨石)
if (voxelGrid[idx] > 0 && currentGrid[idx] === 0) {
// gridY 是相对于 MIN_WORLD_Y 的坐标
// 转换为真实世界坐标
const worldY = MIN_WORLD_Y + gridY;
removedVoxels.add(`${ix}|${worldY}|${iz}`);
}
}
}
}
return { removedVoxels };
}
/**
* 检查某个体素是否应该因为风化而被移除
*
* @param ix 微观x坐标
* @param iy 世界y坐标
* @param iz 微观z坐标
* @param weatheringResult 风化预处理结果
* @returns 是否应该移除该体素
*/
export function shouldRemoveByWeathering(
ix: number,
iy: number,
iz: number,
weatheringResult: GobiWeatheringResult | null
): boolean {
if (!weatheringResult) return false;
return weatheringResult.removedVoxels.has(`${ix}|${iy}|${iz}`);
}
// ==================== 溪流蚀刻效果 ====================
/**
* 检查是否应该应用溪流蚀刻效果
* 在溪流深度范围内的方块会被蚀刻
*
* @param params 溪流蚀刻参数
* @returns 是否在溪流蚀刻范围内
*/
export function shouldApplyStreamEtching(params: {
streamDepthMicro: number;
depth: number;
}): boolean {
const { streamDepthMicro, depth } = params;
return streamDepthMicro > 0 && depth < streamDepthMicro;
}
// ==================== 通用颜色渗透算法 ====================
interface VoxelPenetrationParams {
currentType: VoxelType;
targetType: VoxelType;
noiseValue: number;
threshold: number; // 渗透阈值 (0-1),噪声值大于此阈值则渗透
condition?: boolean; // 可选的额外条件
}
/**
* 通用体素渗透逻辑
* 如果条件满足且噪声值超过阈值,则返回目标类型,否则返回当前类型
*/
function applyVoxelPenetration(params: VoxelPenetrationParams): VoxelType {
const { currentType, targetType, noiseValue, threshold, condition = true } = params;
if (!condition) return currentType;
return noiseValue > threshold ? targetType : currentType;
}
// ==================== 戈壁层颜色渗透 ====================
/**
* 获取戈壁层类型的函数类型
*/
type GetGobiLayerType = (absoluteHeight: number, stackHeight: number, variant: number) => VoxelType;
interface GobiColorBlendingParams {
currentType: VoxelType; // 当前方块类型
microDepthFromBottom: number; // 从戈壁底部算起的微观深度
gobiLogicHeight: number; // 戈壁逻辑高度
gobiMicroHeight: number; // 戈壁微观高度
gobiVariantIdx: number; // 戈壁变体索引
ix: number; // 微观x坐标
iz: number; // 微观z坐标
y: number; // 世界y坐标
MICRO_SCALE: number; // 微观缩放比例
getGobiLayerType: GetGobiLayerType; // 获取戈壁层类型的函数
}
/**
* 应用戈壁层之间的颜色渗透效果
* 在层的顶部和底部创建像素化渗透,模仿泥土方块的自然过渡
*/
export function applyGobiColorBlending(params: GobiColorBlendingParams): VoxelType {
const {
currentType,
microDepthFromBottom,
gobiLogicHeight,
gobiMicroHeight,
gobiVariantIdx,
ix,
iz,
y,
MICRO_SCALE,
getGobiLayerType,
} = params;
let type = currentType;
// 计算在层内的微观位置 (0, 1, 2)
const subH = microDepthFromBottom % MICRO_SCALE;
// 计算当前层级
const layerFromBottom = Math.floor(microDepthFromBottom / MICRO_SCALE) + 1;
const currentLayer = Math.max(1, Math.min(gobiLogicHeight, layerFromBottom));
// 使用位置噪声进行抖动
const pNoise = pseudoRandom(ix * 0.43 + iz * 0.37 + y * 0.41);
// 向上渗透:如果是层的顶部 (subH == MICRO_SCALE - 1),渗透下一层颜色
if (subH === MICRO_SCALE - 1 && currentLayer < gobiLogicHeight) {
const targetType = getGobiLayerType(currentLayer + 1, gobiMicroHeight, gobiVariantIdx);
type = applyVoxelPenetration({
currentType: type,
targetType: targetType,
noiseValue: pNoise,
threshold: 0.45 // 55% 渗透概率
});
}
// 向下渗透:如果是层的底部 (subH == 0),渗透上一层颜色
else if (subH === 0 && currentLayer > 1) {
const targetType = getGobiLayerType(currentLayer - 1, gobiMicroHeight, gobiVariantIdx);
// 注意:这里 pNoise < 0.55 等价于 1-pNoise > 0.45,为了复用函数,我们反转逻辑
// 或者直接使用 applyVoxelPenetration 的逻辑: noise > threshold => target
// 这里原逻辑是 pNoise < 0.55 => target
// 所以 threshold 设为 0.45 (1-0.55),并传入 1-pNoise
type = applyVoxelPenetration({
currentType: type,
targetType: targetType,
noiseValue: 1 - pNoise,
threshold: 0.45
});
}
return type;
}
// ==================== 巨石颜色渗透 ====================
interface StoneColorBlendingParams {
currentType: VoxelType;
depth: number; // 从顶部算起的深度
stoneVariantIdx: number;
ix: number;
iz: number;
y: number;
}
/**
* 应用巨石颜色渗透
* 让深色石头和浅色石头在交界处互相渗透,增加自然感
*/
export function applyStoneColorBlending(params: StoneColorBlendingParams): VoxelType {
const { currentType, depth, ix, iz, y } = params;
// stoneVariantIdx 暂时保留在接口中以备将来使用
// 巨石只有两种stone (浅) 和 dark_stone (深)
// 目前的生成逻辑是variant % 2 === 0 ? 'stone' : 'dark_stone'
// 也就是整个巨石是纯色的。
// 我们引入一种模式:基于深度或噪声的混合
// 1. 基础渗透:使用 3D 噪声让表面产生斑驳
const noise = pseudoRandom(ix * 0.23 + iz * 0.27 + y * 0.31);
// 定义另一种石头类型
const altType: VoxelType = currentType === 'stone' ? 'dark_stone' : 'stone';
// 2. 边缘/风化渗透:在石头边缘或某些层级增加杂色
// 15% 的概率出现杂色斑点
// [优化] 使用 depth 参数:越接近顶部 (depth 小),风化越严重 (渗透概率越高)
// 顶部 3 层增加额外的 10% 渗透概率
let threshold = 0.85;
if (depth < 3) threshold = 0.75;
return applyVoxelPenetration({
currentType,
targetType: altType,
noiseValue: noise,
threshold: threshold
});
}
// ==================== 泥土分层渐变 ====================
interface DirtLayerBlendingParams {
currentType: VoxelType; // 当前方块类型
y: number; // 世界y坐标
ix: number; // 微观x坐标
iz: number; // 微观z坐标
MIN_WORLD_Y: number; // 最小世界Y坐标
noiseInfluence: number; // 噪声影响值
isDesertScene: boolean; // 是否沙漠场景
isInGobiRange: boolean; // 是否在戈壁范围
isInStoneRange: boolean; // 是否在巨石范围
}
/**
* 应用泥土分层渐变效果
* 四层平滑渐变deep_dirt -> medium_dirt -> dirt带边界渗透效果
*/
export function applyDirtLayerBlending(params: DirtLayerBlendingParams): VoxelType {
const {
currentType,
y,
ix,
iz,
MIN_WORLD_Y,
noiseInfluence,
isDesertScene,
isInGobiRange,
isInStoneRange,
} = params;
let type = currentType;
// 只处理泥土类型,且沙漠场景中不处理戈壁/巨石区域
if (type !== 'dirt' || (isDesertScene && (isInGobiRange || isInStoneRange))) {
return type;
}
// ========== 第一步:基础分层 ==========
// ... (保持原有分层逻辑不变,这部分不是渗透,是区域定义) ...
// 深层泥土核心区
if (y > MIN_WORLD_Y + 1 && y <= MIN_WORLD_Y + 5) type = 'deep_dirt';
// 深层到中层过渡区
else if (y > MIN_WORLD_Y + 5 && y <= MIN_WORLD_Y + 10) {
const transitionDepth = (y - MIN_WORLD_Y - 5) / 5;
const smoothT = transitionDepth * transitionDepth * (3 - 2 * transitionDepth);
const noiseEffect = noiseInfluence * 0.2;
const finalT = clamp(smoothT + noiseEffect, 0, 1);
type = finalT > 0.5 ? 'medium_dirt' : 'deep_dirt';
}
// 中层泥土核心区
else if (y > MIN_WORLD_Y + 10 && y <= MIN_WORLD_Y + 13) type = 'medium_dirt';
// 中层到浅层过渡区
else if (y > MIN_WORLD_Y + 13 && y <= MIN_WORLD_Y + 18) {
const transitionDepth = (y - MIN_WORLD_Y - 13) / 5;
const smoothT = transitionDepth * transitionDepth * (3 - 2 * transitionDepth);
const noiseEffect = noiseInfluence * 0.2;
const finalT = clamp(smoothT + noiseEffect, 0, 1);
type = finalT > 0.5 ? 'dirt' : 'medium_dirt';
}
// ========== 第二步:边界渗透效果 (使用通用函数重构) ==========
// 预计算噪声
const positionNoise1 = pseudoRandom(ix * 0.17 + iz * 0.23 + y * 0.31);
const positionNoise2 = pseudoRandom(ix * 0.19 + iz * 0.27 + y * 0.33);
const positionNoise3 = pseudoRandom(ix * 0.21 + iz * 0.29 + y * 0.35);
const combinedNoise = (positionNoise1 + positionNoise2 + positionNoise3) / 3;
// 辅助:应用特定区域的渗透
const applyZonePenetration = (
targetYMin: number, targetYMax: number,
boundaryProb: number,
penetrationProb: number, // 0-1, 越高越容易渗透
typeA: VoxelType, typeB: VoxelType // A <-> B 相互渗透
) => {
if (y > targetYMin && y <= targetYMax) {
const boundaryNoise = pseudoRandom(ix * 0.13 + iz * 0.19 + y * 0.27);
if (boundaryNoise > (1 - boundaryProb)) {
// 决定目标类型
let target = type;
if (combinedNoise < penetrationProb * 0.5) target = typeA; // 倾向A
else if (combinedNoise < penetrationProb) target = typeB; // 倾向B
if (target !== type) return target;
}
}
return type;
};
// 深层 -> 中层 过渡区渗透
type = applyZonePenetration(
MIN_WORLD_Y + 5, MIN_WORLD_Y + 10,
0.4, 0.5,
'deep_dirt', 'medium_dirt'
);
// 中层 -> 浅层 过渡区渗透
type = applyZonePenetration(
MIN_WORLD_Y + 13, MIN_WORLD_Y + 18,
0.4, 0.5,
'medium_dirt', 'dirt'
);
// 核心区域边界渗透 (重构为更简洁的形式)
// 这里原本的逻辑比较复杂,为了保持视觉效果一致,我们保留原有的核心逻辑结构,
// 但使用 applyVoxelPenetration 来执行最终的类型切换
if (
(y >= MIN_WORLD_Y + 3 && y <= MIN_WORLD_Y + 7) ||
(y >= MIN_WORLD_Y + 8 && y <= MIN_WORLD_Y + 12) ||
(y >= MIN_WORLD_Y + 11 && y <= MIN_WORLD_Y + 15) ||
(y >= MIN_WORLD_Y + 16 && y <= MIN_WORLD_Y + 20)
) {
const edgeNoise = pseudoRandom(ix * 0.19 + iz * 0.23 + y * 0.37);
// 渗透阈值 (原逻辑 edgeNoise > 0.7 => 30% 概率)
if (edgeNoise > 0.7) {
let prob = 0.25;
if ([5, 10, 13, 18].includes(y - MIN_WORLD_Y)) prob = 0.4; // 边界处概率更高
// 这里的 combinedNoise < prob 相当于 noise > (1-prob)
// 我们根据区域决定目标类型
let target: VoxelType = type;
// 简化判断逻辑
const pick = (t1: VoxelType, t2: VoxelType) => combinedNoise < prob * 0.5 ? t1 : t2;
const pick3 = (t1: VoxelType, t2: VoxelType, t3: VoxelType) => combinedNoise < prob * 0.33 ? t1 : (combinedNoise < prob * 0.66 ? t2 : t3);
if (y >= MIN_WORLD_Y + 3 && y <= MIN_WORLD_Y + 7) target = pick('medium_dirt', 'deep_dirt');
else if (y >= MIN_WORLD_Y + 8 && y <= MIN_WORLD_Y + 12) target = pick3('deep_dirt', 'dirt', 'medium_dirt');
else if (y >= MIN_WORLD_Y + 11 && y <= MIN_WORLD_Y + 15) target = pick3('dirt', 'deep_dirt', 'medium_dirt');
else if (y >= MIN_WORLD_Y + 16 && y <= MIN_WORLD_Y + 20) target = pick('medium_dirt', 'dirt');
// 使用通用函数执行切换 (condition已在上面判断所以 threshold=0)
type = applyVoxelPenetration({
currentType: type,
targetType: target,
noiseValue: 1, // 强制触发
threshold: 0,
condition: combinedNoise < prob // 保持原有的概率检查
});
}
}
return type;
}

View File

@@ -0,0 +1,296 @@
import type { VoxelType } from './terrain';
// 场景配置接口
export interface SceneConfig {
name: string;
displayName: string;
// 地形高度参数
heightScale: number; // 高度缩放因子
heightBase: number; // 基础高度偏移
heightRoughness: number; // 地形粗糙度(噪声频率)
heightAmplitude: number; // 高度变化幅度
// 水位配置
waterLevel: number;
hasWater: boolean;
waterType: VoxelType; // 水的类型water或murky_water
// 表面方块配置
surfaceBlock: VoxelType; // 表层方块
subSurfaceBlock: VoxelType; // 次表层方块
deepBlock: VoxelType; // 深层方块
// 植被配置
vegetationDensity: number; // 植被密度 (0-1)
vegetationTypes: Array<{
type: VoxelType;
probability: number;
minHeight?: number;
maxHeight?: number;
}>;
// 特殊地物配置
specialFeatures: Array<{
type: 'lava_pool' | 'ice_spike' | 'swamp_tree' | 'cactus' | 'volcanic_vent';
probability: number;
}>;
// 生物群系权重(用于多样化)
biomeWeights: {
hot: number; // 炎热区域权重
cold: number; // 寒冷区域权重
wet: number; // 潮湿区域权重
dry: number; // 干燥区域权重
};
// 颜色调整
colorMultipliers?: {
r: number;
g: number;
b: number;
};
}
// 场景类型定义
export type SceneType =
| 'desert'
| 'mountain'
| 'snowy_mountain'
| 'riverside'
| 'beach'
| 'plains'
| 'volcano'
| 'swamp'
| 'tundra';
// 场景配置集合
export const SCENE_CONFIGS: Record<SceneType, SceneConfig> = {
// 1. 沙漠场景
desert: {
name: 'desert',
displayName: 'Desert',
heightScale: 0.15, // 极低,创建平坦基础
heightBase: 2,
heightRoughness: 0.008, // 极低粗糙度,几乎平坦
heightAmplitude: 1, // 极小的高度变化
waterLevel: -5,
hasWater: false,
waterType: 'water',
surfaceBlock: 'sand',
subSurfaceBlock: 'sand',
deepBlock: 'stone',
vegetationDensity: 0.05,
vegetationTypes: [
{ type: 'cactus', probability: 0.8, minHeight: 0, maxHeight: 10 }
],
specialFeatures: [
{ type: 'cactus', probability: 0.08 }
],
biomeWeights: { hot: 1.0, cold: 0.0, wet: 0.1, dry: 1.0 }
},
// 2. 山区场景
mountain: {
name: 'mountain',
displayName: 'Mountain',
heightScale: 1.8,
heightBase: 4,
heightRoughness: 0.04,
heightAmplitude: 8,
waterLevel: 1,
hasWater: true,
waterType: 'water',
surfaceBlock: 'grass',
subSurfaceBlock: 'dirt',
deepBlock: 'stone',
vegetationDensity: 0.25,
vegetationTypes: [
{ type: 'wood', probability: 0.7, minHeight: 0, maxHeight: 6 },
{ type: 'flower', probability: 0.3, minHeight: 0, maxHeight: 4 }
],
specialFeatures: [],
biomeWeights: { hot: 0.3, cold: 0.5, wet: 0.6, dry: 0.4 }
},
// 3. 雪山场景
snowy_mountain: {
name: 'snowy_mountain',
displayName: 'Snowy Mountain',
heightScale: 2.2,
heightBase: 5,
heightRoughness: 0.05,
heightAmplitude: 12,
waterLevel: -2,
hasWater: true,
waterType: 'ice',
surfaceBlock: 'snow',
subSurfaceBlock: 'packed_ice',
deepBlock: 'frozen_stone',
vegetationDensity: 0.08,
vegetationTypes: [
{ type: 'wood', probability: 0.6, minHeight: 0, maxHeight: 4 }
],
specialFeatures: [
{ type: 'ice_spike', probability: 0.04 }
],
biomeWeights: { hot: 0.0, cold: 1.0, wet: 0.4, dry: 0.6 }
},
// 4. 河岸场景
riverside: {
name: 'riverside',
displayName: 'Riverside',
heightScale: 0.6,
heightBase: 3,
heightRoughness: 0.025,
heightAmplitude: 4,
waterLevel: 1.5,
hasWater: true,
waterType: 'water',
surfaceBlock: 'grass',
subSurfaceBlock: 'dirt',
deepBlock: 'stone',
vegetationDensity: 0.35,
vegetationTypes: [
{ type: 'wood', probability: 0.5, minHeight: 0, maxHeight: 5 },
{ type: 'reed', probability: 0.4, minHeight: 0, maxHeight: 2 },
{ type: 'flower', probability: 0.1, minHeight: 0, maxHeight: 3 }
],
specialFeatures: [],
biomeWeights: { hot: 0.5, cold: 0.3, wet: 1.0, dry: 0.2 }
},
// 5. 海滩场景
beach: {
name: 'beach',
displayName: 'Beach',
heightScale: 0.25,
heightBase: 1.5,
heightRoughness: 0.02,
heightAmplitude: 2,
waterLevel: 1.8,
hasWater: true,
waterType: 'water',
surfaceBlock: 'sand',
subSurfaceBlock: 'sand',
deepBlock: 'stone',
vegetationDensity: 0.12,
vegetationTypes: [
{ type: 'wood', probability: 0.8, minHeight: 2, maxHeight: 10 }
],
specialFeatures: [],
biomeWeights: { hot: 0.8, cold: 0.2, wet: 0.7, dry: 0.6 }
},
// 6. 平原场景
plains: {
name: 'plains',
displayName: 'Plains',
heightScale: 0.4,
heightBase: 2.5,
heightRoughness: 0.02,
heightAmplitude: 3,
waterLevel: 1,
hasWater: true,
waterType: 'water',
surfaceBlock: 'grass',
subSurfaceBlock: 'dirt',
deepBlock: 'stone',
vegetationDensity: 0.2,
vegetationTypes: [
{ type: 'wood', probability: 0.5, minHeight: 0, maxHeight: 8 },
{ type: 'flower', probability: 0.5, minHeight: 0, maxHeight: 5 }
],
specialFeatures: [],
biomeWeights: { hot: 0.6, cold: 0.4, wet: 0.5, dry: 0.5 }
},
// 7. 火山场景
volcano: {
name: 'volcano',
displayName: 'Volcano',
heightScale: 2.0,
heightBase: 4,
heightRoughness: 0.06,
heightAmplitude: 10,
waterLevel: -5,
hasWater: false,
waterType: 'lava',
surfaceBlock: 'volcanic_rock',
subSurfaceBlock: 'magma_stone',
deepBlock: 'obsidian',
vegetationDensity: 0.02,
vegetationTypes: [],
specialFeatures: [
{ type: 'lava_pool', probability: 0.15 },
{ type: 'volcanic_vent', probability: 0.05 }
],
biomeWeights: { hot: 1.0, cold: 0.0, wet: 0.1, dry: 1.0 }
},
// 8. 沼泽场景
swamp: {
name: 'swamp',
displayName: 'Swamp',
heightScale: 0.2,
heightBase: 1.8,
heightRoughness: 0.018,
heightAmplitude: 2,
waterLevel: 1.6,
hasWater: true,
waterType: 'murky_water',
surfaceBlock: 'swamp_grass',
subSurfaceBlock: 'mud',
deepBlock: 'mud',
vegetationDensity: 0.4,
vegetationTypes: [
{ type: 'wood', probability: 0.6, minHeight: 0, maxHeight: 4 },
{ type: 'moss', probability: 0.3, minHeight: 0, maxHeight: 3 },
{ type: 'lily_pad', probability: 0.1, minHeight: 0, maxHeight: 1 }
],
specialFeatures: [
{ type: 'swamp_tree', probability: 0.12 }
],
biomeWeights: { hot: 0.7, cold: 0.2, wet: 1.0, dry: 0.1 }
},
// 9. 冰原场景
tundra: {
name: 'tundra',
displayName: 'Tundra',
heightScale: 0.3,
heightBase: 2,
heightRoughness: 0.022,
heightAmplitude: 3,
waterLevel: -2,
hasWater: true,
waterType: 'ice',
surfaceBlock: 'snow',
subSurfaceBlock: 'permafrost',
deepBlock: 'frozen_stone',
vegetationDensity: 0.03,
vegetationTypes: [],
specialFeatures: [
{ type: 'ice_spike', probability: 0.06 }
],
biomeWeights: { hot: 0.0, cold: 1.0, wet: 0.3, dry: 0.7 }
}
};
// 获取所有场景类型
export const getAllSceneTypes = (): SceneType[] => {
return Object.keys(SCENE_CONFIGS) as SceneType[];
};
// 获取随机场景类型
export const getRandomSceneType = (): SceneType => {
const types = getAllSceneTypes();
return types[Math.floor(Math.random() * types.length)];
};
// 获取场景配置
export const getSceneConfig = (sceneType: SceneType): SceneConfig => {
return SCENE_CONFIGS[sceneType];
};

View File

@@ -0,0 +1,929 @@
/**
* 地形生成主入口:协调所有地形生成模块
* - 整合方块样式系统 (voxelStyles)
* - 整合戈壁特征地形 (desertFeatures)
* - 整合植被生成 (vegetation)
* - 整合后处理 (postprocessing)
*/
import { createNoise2D } from 'simplex-noise';
import { getSceneConfig, type SceneType } from './scenes';
// 方块样式和色板
import {
type VoxelType,
pseudoRandom,
createDeterministicRandom,
varyColor,
} from './voxelStyles';
// 戈壁特征地形
import {
createDesertContext,
getGobiLayerType,
getStoneLayerType,
// DESERT_ROCK_STRUCTS,
// STONE_PLATFORM_STRUCTS,
// MESA_PLATFORM_STRUCTS,
} from './desertFeatures';
// 植被生成 (已移除旧系统)
// import {
// type AddTreeVoxelFn,
// pickRandom,
// LUSH_TREES,
// CONIFER_TREES,
// GRASS_DECO,
// FLOWER_STRUCTS,
// REED_STRUCTS,
// CACTUS_STRUCTS,
// placeStructure,
// placeGobiVegetation,
// } from './vegetation';
// 新的植被系统
import { generateVegetation, type VegetationGenerationContext } from './newVegetation';
// 后处理
import {
shouldApplyStreamEtching,
applyGobiColorBlending,
applyStoneColorBlending, // 引入新函数
applyDirtLayerBlending,
preprocessGobiWeathering,
shouldRemoveByWeathering,
type GobiWeatheringResult,
} from './postprocessing';
// ============= 导出类型 =============
export type { VoxelType };
// ============= 进度回调类型 =============
export interface TerrainGenerationProgress {
stage: 'basic' | 'features' | 'postprocessing' | 'vegetation' | 'complete';
stageLabel: string;
progress: number; // 0-100
detail?: string;
}
export type ProgressCallback = (progress: TerrainGenerationProgress) => void;
// ============= 地形生成配置 =============
// 地形生成版本号 - 每次修改时改变这个数字,触发地图重新生成
export const TERRAIN_VERSION = 83;
// 逻辑层面的网格大小(战棋移动格子)
export const TILE_SIZE = 1;
// 每个逻辑格子包含的微型体素数量 (8x8) - 用于地形
export const MICRO_SCALE = 8;
// 树木专用的高分辨率微体素数量 (32x32) - 用于更精细的树木细节
export const TREE_MICRO_SCALE = 32;
// 微型体素的物理尺寸
export const VOXEL_SIZE = TILE_SIZE / MICRO_SCALE;
// 树木微体素的物理尺寸(更小,更精细)
export const TREE_VOXEL_SIZE = TILE_SIZE / TREE_MICRO_SCALE;
export interface VoxelData {
x: number;
y: number;
z: number;
type: VoxelType;
color: string;
ix: number;
iy: number;
iz: number;
heightScale: number;
isHighRes?: boolean; // 标记是否为高分辨率(16x16)体素
}
export const MAP_SIZES = {
small: 16,
medium: 24,
large: 32,
};
// 地形生成选项
export interface TerrainGenerationOptions {
// 戈壁风化参数
weatheringMaxIterations?: number; // 戈壁风化迭代次数,默认 3
weatheringHeightBias?: number; // 戈壁高度保护阈值,默认 0.5
// 巨石风化参数
stoneWeatheringMaxIterations?: number; // 巨石风化迭代次数,默认 3
stoneWeatheringHeightBias?: number; // 巨石高度保护阈值,默认 0.5
stoneWeatheringMinExposedFaces?: number; // 巨石最少暴露面数,默认 2
}
// MagicaVoxel Vibrant Palette (More Saturation, Less Grey)
export const generateTerrain = async (
mapSize: number,
seed: string = 'default',
sceneType?: SceneType,
onProgress?: ProgressCallback,
options?: TerrainGenerationOptions
): Promise<VoxelData[]> => {
// 进度报告辅助函数 - 直接同步调用,确保状态立即更新
const reportProgress = (stage: TerrainGenerationProgress['stage'], progress: number, detail?: string) => {
if (!onProgress) return;
const stageLabels = {
basic: '生成基础地形',
features: '生成特征地形',
postprocessing: '地貌后处理',
vegetation: '生成植被',
complete: '完成'
};
const progressData = {
stage,
stageLabel: stageLabels[stage],
progress,
detail
};
// 直接同步调用,确保状态立即更新
onProgress(progressData);
};
let seedVal = 0;
for (let i = 0; i < seed.length; i++) seedVal += seed.charCodeAt(i);
const randomSeedBase = seedVal || 1;
const seededRandom = createDeterministicRandom(randomSeedBase);
// 获取场景配置
const sceneConfig = sceneType ? getSceneConfig(sceneType) : undefined;
const noise2D = createNoise2D(() => {
seedVal = Math.imul(seedVal ^ 0xdeadbeef, 2654435761);
return ((seedVal >>> 0) / 4294967296);
});
const moistureNoise = createNoise2D(() => Math.random());
const detailNoise = createNoise2D(() => Math.random());
// 树木聚集噪声:用于控制树木成组生成 (已移除)
// const forestClusterNoise = createNoise2D(() => Math.random());
const desertContext = sceneConfig?.name === 'desert'
? createDesertContext(mapSize, seededRandom)
: null;
const isDesertScene = sceneConfig?.name === 'desert';
// 地形类型决策:如果有场景配置,使用场景配置;否则根据 seed 生成
let terrainType: string;
let heightBase: number;
let heightRoughness: number;
let waterLevel: number;
let surfaceBlock: VoxelType;
let subSurfaceBlock: VoxelType;
let deepBlock: VoxelType;
if (sceneConfig) {
terrainType = sceneConfig.name;
heightBase = sceneConfig.heightBase;
heightRoughness = sceneConfig.heightRoughness;
waterLevel = sceneConfig.waterLevel;
surfaceBlock = sceneConfig.surfaceBlock;
subSurfaceBlock = sceneConfig.subSurfaceBlock;
deepBlock = sceneConfig.deepBlock;
} else {
// 旧的随机地形类型逻辑(向后兼容)
const terrainTypeHash = seedVal % 4;
terrainType = ['normal', 'mountain', 'desert', 'canyon'][terrainTypeHash];
heightBase = 3;
heightRoughness = 0.03;
waterLevel = 1;
surfaceBlock = 'grass';
subSurfaceBlock = 'dirt';
deepBlock = 'stone';
}
const MIN_WORLD_Y = -6; // ensure solid base
// 开始生成:基础地形
reportProgress('basic', 0, '初始化地形生成');
// ===== 戈壁和巨石风化预处理 =====
// 在沙漠场景中,预先计算风化效果
let gobiWeatheringResult: GobiWeatheringResult | null = null;
if (isDesertScene && desertContext) {
reportProgress('basic', 5, '预处理戈壁和巨石风化效果');
await new Promise(resolve => setTimeout(resolve, 0));
// 从options中获取参数如果没有则使用默认值
const gobiMaxIterations = options?.weatheringMaxIterations ?? 3;
const gobiHeightBias = options?.weatheringHeightBias ?? 0.5;
const stoneMaxIterations = options?.stoneWeatheringMaxIterations ?? 3;
const stoneHeightBias = options?.stoneWeatheringHeightBias ?? 0.5;
const stoneMinExposedFaces = options?.stoneWeatheringMinExposedFaces ?? 2;
gobiWeatheringResult = preprocessGobiWeathering({
desertContext,
mapSize,
MICRO_SCALE,
gobiMaxIterations,
gobiHeightBias,
stoneMaxIterations,
stoneHeightBias,
stoneMinExposedFaces,
MIN_WORLD_Y,
});
reportProgress('basic', 10, `风化预处理完成,移除 ${gobiWeatheringResult.removedVoxels.size} 个体素`);
await new Promise(resolve => setTimeout(resolve, 0));
}
const voxels: VoxelData[] = [];
const occupancy = new Set<string>();
const keyOf = (x: number, y: number, z: number) => `${x}|${y}|${z}`;
const topColumns = new Map<string, { index: number; baseType: VoxelType }>();
// 记录树木位置
const treePositions: Array<{ x: number, z: number }> = [];
// 定义添加体素的函数类型
type AddVoxelFn = (
ix: number,
iy: number,
iz: number,
type: VoxelType,
color: string,
heightScale?: number
) => void;
const addVoxel: AddVoxelFn = (ix, iy, iz, type, color, heightScale = 1) => {
const voxel: VoxelData = {
x: ix * VOXEL_SIZE,
y: iy * VOXEL_SIZE,
z: iz * VOXEL_SIZE,
type,
color,
ix,
iy,
iz,
heightScale,
};
voxels.push(voxel);
occupancy.add(keyOf(ix, iy, iz));
};
// 专门用于添加树木体素的函数 (已移除)
// const addTreeVoxel...
for (let lx = 0; lx < mapSize; lx++) {
// 报告基础地形生成进度 + 异步让出控制权
if (lx % Math.max(1, Math.floor(mapSize / 10)) === 0) {
reportProgress('basic', Math.floor((lx / mapSize) * 100), `生成地形列 ${lx}/${mapSize}`);
// 关键让浏览器有机会渲染UI
await new Promise(resolve => setTimeout(resolve, 0));
}
for (let lz = 0; lz < mapSize; lz++) {
let n, logicHeight, logicType: VoxelType;
const moisture = moistureNoise(lx * 0.15, lz * 0.15);
const gobiLogicHeight = isDesertScene && desertContext ? desertContext.gobiHeight[lx][lz] : 0;
const gobiMicroHeight = gobiLogicHeight * MICRO_SCALE;
const gobiVariantIdx = isDesertScene && desertContext ? desertContext.gobiVariant[lx][lz] : 0;
const stoneLogicHeight = isDesertScene && desertContext ? desertContext.stoneHeight[lx][lz] : 0;
const stoneMicroHeight = stoneLogicHeight * MICRO_SCALE;
const stoneVariantIdx = isDesertScene && desertContext ? desertContext.stoneVariant[lx][lz] : 0;
// Pre-fetch neighbor heights for Gobi smoothing (not used in current erosion algorithm)
// Kept for potential future use
// let gobiH_L = 0, gobiH_R = 0, gobiH_U = 0, gobiH_D = 0;
// if (isDesertScene && desertContext && gobiLogicHeight > 0) {
// const gh = desertContext.gobiHeight;
// gobiH_L = gh[lx-1]?.[lz] ?? 0;
// gobiH_R = gh[lx+1]?.[lz] ?? 0;
// gobiH_U = gh[lx]?.[lz-1] ?? 0;
// gobiH_D = gh[lx]?.[lz+1] ?? 0;
// }
// 使用场景配置生成地形
if (sceneConfig) {
if (terrainType === 'desert') {
logicHeight = Math.max(1, Math.floor(heightBase));
logicType = 'sand';
} else {
const scale = heightRoughness;
n = noise2D(lx * scale, lz * scale);
n += 0.5 * noise2D(lx * scale * 2, lz * scale * 2);
n /= 1.5;
logicHeight = Math.floor((n + 1) * sceneConfig.heightAmplitude * 0.5 + heightBase);
if (logicHeight < 0) logicHeight = 0;
if (sceneConfig.hasWater && logicHeight <= waterLevel) {
logicType = sceneConfig.waterType;
} else {
const relativeHeight = logicHeight - waterLevel;
if (terrainType === 'volcano') {
if (logicHeight < heightBase - 1) {
logicType = 'lava';
} else if (logicHeight < heightBase + 2) {
logicType = moisture > 0.3 ? 'ash' : 'volcanic_rock';
} else if (logicHeight < heightBase + 6) {
logicType = 'volcanic_rock';
} else {
logicType = moisture > 0.5 ? 'magma_stone' : 'obsidian';
}
} else if (terrainType === 'swamp') {
if (relativeHeight <= 0.5) {
logicType = 'mud';
} else {
logicType = moisture > 0.3 ? 'swamp_grass' : 'mud';
}
} else if (terrainType === 'tundra' || terrainType === 'snowy_mountain') {
if (relativeHeight < 1) {
logicType = 'permafrost';
} else if (relativeHeight < 3) {
logicType = 'snow';
} else {
logicType = moisture > 0.4 ? 'packed_ice' : 'frozen_stone';
}
} else {
logicType = surfaceBlock;
}
}
}
} else {
// 旧逻辑(向后兼容)
// 根据地形类型生成不同的地貌
if (terrainType === 'mountain') {
// ===== 高山地形:高耸的山峰,无水系 =====
const scale = 0.06;
n = noise2D(lx * scale, lz * scale);
n += 0.7 * noise2D(lx * scale * 2, lz * scale * 2);
n /= 1.7;
logicHeight = Math.floor((n + 1) * 5); // 更高的高度范围 (0-10)
if (logicHeight < 2) logicHeight = 2; // 无水体
if (logicHeight < 4) {
logicType = 'grass';
} else if (logicHeight < 7) {
logicType = 'stone';
} else {
logicType = 'snow';
}
} else if (terrainType === 'desert') {
// ===== 戈壁沙漠:沙地和岩石组成,无水系 =====
const scale = 0.1;
n = noise2D(lx * scale + 300, lz * scale + 300);
n += 0.3 * noise2D(lx * scale * 3, lz * scale * 3);
n /= 1.3;
logicHeight = Math.floor((n + 1) * 2.5); // 较平坦 (0-5)
if (logicHeight < 2) logicHeight = 2; // 无水体
if (logicHeight < 4) {
logicType = moisture > -0.2 ? 'sand' : 'stone'; // 大部分是沙,少量岩石
} else {
logicType = 'stone'; // 戈壁岩石
}
} else if (terrainType === 'canyon') {
// ===== 峡谷地形:高度差大,中央有河流 =====
const scale = 0.08;
n = noise2D(lx * scale + 600, lz * scale + 600);
n += 0.6 * noise2D(lx * scale * 2, lz * scale * 2);
n /= 1.6;
// 河流:地图中央从上到下的一条带
const distToRiver = Math.abs(lx - mapSize / 2);
const riverWidth = mapSize * 0.08; // 河流宽度约为地图的 8%
if (distToRiver < riverWidth) {
// 河流区域:深度根据距离河流中心的距离
const depthFactor = 1 - (distToRiver / riverWidth);
logicHeight = Math.max(0, Math.floor(2 - depthFactor * 2)); // 河流深度 0-2
logicType = 'water';
} else {
// 峡谷两侧:高度剧烈变化
const sideEffect = Math.sin(lz * 0.15) * 0.3;
logicHeight = Math.floor((n + sideEffect + 1) * 4.5); // 高度范围 (0-9)
if (logicHeight <= 2) {
logicType = 'grass';
} else if (logicHeight < 6) {
logicType = 'stone';
} else {
logicType = 'snow';
}
}
} else {
// ===== 普通地形:原有逻辑 =====
const scale = 0.08;
n = noise2D(lx * scale + 100, lz * scale + 100);
n += 0.5 * noise2D(lx * scale * 2 + 200, lz * scale * 2 + 200);
n /= 1.5;
logicHeight = Math.floor((n + 1) * 3.2);
if (logicHeight < 1) logicHeight = 1;
// 创建大型湖泊(地图中心偏左下区域)
const lakeCenterX = mapSize * 0.35;
const lakeCenterZ = mapSize * 0.4;
const distToLake = Math.sqrt(
Math.pow(lx - lakeCenterX, 2) + Math.pow(lz - lakeCenterZ, 2)
);
const lakeRadius = mapSize * 0.12;
if (distToLake < lakeRadius) {
const depthFactor = 1 - (distToLake / lakeRadius);
logicHeight = Math.max(0, Math.floor(1 - depthFactor * 0.5));
logicType = 'water';
} else if (logicHeight <= 1) {
logicType = 'water';
} else if (logicHeight === 2) {
logicType = moisture > 0 ? 'grass' : 'sand';
} else if (logicHeight < 5) {
logicType = 'grass';
if (n > 0.7) logicType = 'stone';
} else {
logicType = 'stone';
if (logicHeight > 7) logicType = 'snow';
}
}
}
for (let mx = 0; mx < MICRO_SCALE; mx++) {
for (let mz = 0; mz < MICRO_SCALE; mz++) {
const ix = lx * MICRO_SCALE + mx;
const iz = lz * MICRO_SCALE + mz;
// Much subtler detail noise
// Use lower frequency for broader, gentler slopes
const detailVal = detailNoise(ix * 0.08, iz * 0.08);
let worldY = logicHeight * MICRO_SCALE;
if (isDesertScene) {
worldY += stoneMicroHeight + gobiMicroHeight;
}
// SMOOTHING LOGIC:
// Only apply significant noise to Stone/Snow
// Keep Grass/Sand relatively flat (just +/- 1 voxel occasionally)
if (logicType === 'grass' || logicType === 'sand' || logicType === 'swamp_grass') {
if (Math.abs(detailVal) > 0.85) {
worldY += Math.sign(detailVal);
}
} else if (logicType === 'stone' || logicType === 'snow' || logicType === 'volcanic_rock' || logicType === 'frozen_stone') {
worldY += Math.floor(detailVal * 2);
} else if (logicType === 'water' || logicType === 'murky_water' || logicType === 'lava' || logicType === 'ice') {
worldY = Math.floor(1.3 * MICRO_SCALE);
} else if (logicType === 'ash' || logicType === 'mud') {
if (Math.abs(detailVal) > 0.75) {
worldY += Math.sign(detailVal);
}
}
// 4. Sand Mounds at the base (External Sand Blending) - Pre-calculation
let sandMoundHeight = 0;
const streamInfo = isDesertScene && desertContext
? desertContext.streamDepthMap.get(`${ix}|${iz}`)
: undefined;
const streamDepthMicro = streamInfo?.depth ?? 0;
// 只有非河流区域才生成沙丘
if (streamDepthMicro === 0 && isDesertScene && logicType === 'sand' && gobiLogicHeight === 0 && stoneLogicHeight === 0) {
// Check if we are near a Gobi hill
const neighborGobiH_L = desertContext?.gobiHeight[lx-1]?.[lz] ?? 0;
const neighborGobiH_R = desertContext?.gobiHeight[lx+1]?.[lz] ?? 0;
const neighborGobiH_U = desertContext?.gobiHeight[lx]?.[lz-1] ?? 0;
const neighborGobiH_D = desertContext?.gobiHeight[lx]?.[lz+1] ?? 0;
// 方案A补充检查周围是否有巨石如果有则禁用沙丘
// 检查2格范围内是否有巨石
let hasNearbyStone = false;
for (let checkDx = -2; checkDx <= 2; checkDx++) {
for (let checkDz = -2; checkDz <= 2; checkDz++) {
const checkLx = lx + checkDx;
const checkLz = lz + checkDz;
if (checkLx >= 0 && checkLx < mapSize && checkLz >= 0 && checkLz < mapSize) {
if ((desertContext?.stoneHeight[checkLx]?.[checkLz] ?? 0) > 0) {
hasNearbyStone = true;
break;
}
}
}
if (hasNearbyStone) break;
}
// 只有在附近没有巨石且相邻戈壁时才生成沙丘
if (!hasNearbyStone && (neighborGobiH_L > 0 || neighborGobiH_R > 0 || neighborGobiH_U > 0 || neighborGobiH_D > 0)) {
// We are adjacent to a Gobi hill and far from stones
// Calculate distance to the hill in micro-voxels
let distToHill = Infinity;
if (neighborGobiH_L > 0) distToHill = Math.min(distToHill, mx);
if (neighborGobiH_R > 0) distToHill = Math.min(distToHill, (MICRO_SCALE - 1) - mx);
if (neighborGobiH_U > 0) distToHill = Math.min(distToHill, mz);
if (neighborGobiH_D > 0) distToHill = Math.min(distToHill, (MICRO_SCALE - 1) - mz);
// Generate mounds close to the hill
if (distToHill < 4) {
const moundNoise = pseudoRandom(ix * 0.47 + iz * 0.47 + logicHeight * 0.47);
if (distToHill <= 1) {
if (moundNoise > 0.3) sandMoundHeight = 1;
if (moundNoise > 0.8) sandMoundHeight = 2;
} else if (distToHill <= 2) {
if (moundNoise > 0.6) sandMoundHeight = 1;
}
}
}
}
// Apply sand mound height to worldY
worldY += sandMoundHeight;
// 河流生成:强制拉平水面
// 1. 消除地形微噪声的影响(对于河流区域)
// 2. 减去河道深度
if (streamDepthMicro > 0) {
// 还原到基础平面高度,去除 noise/smoothing 的影响
const baseWorldY = logicHeight * MICRO_SCALE;
// 如果当前已经受 Gobi/Stone 影响抬高了,也要考虑还原(但通常河流避开了它们)
// 这里我们假设河流区域应当是平坦的沙地基础
worldY = baseWorldY; // 重置为绝对平坦
worldY -= streamDepthMicro; // 挖坑
}
const surfaceY = worldY;
for (let y = surfaceY; y >= MIN_WORLD_Y; y--) {
let type: VoxelType = logicType;
const depth = surfaceY - y;
// 沙漠场景:优先处理戈壁和巨石,确保它们保持纯色
const isInGobiRange = gobiMicroHeight > 0 && depth >= stoneMicroHeight && depth < stoneMicroHeight + gobiMicroHeight;
const isInStoneRange = stoneMicroHeight > 0 && depth < stoneMicroHeight;
// 应用溪流蚀刻效果
if (shouldApplyStreamEtching({ streamDepthMicro, depth })) {
type = 'etched_sand';
// CENTER OF STREAM = DARK STONE / GRAVEL
if (streamInfo?.isCenter) {
// Simple noise for gravel patchiness
if (pseudoRandom(ix * 0.3 + iz * 0.3) > 0.4) {
type = 'dark_stone';
}
}
} else if (isInStoneRange) {
// 巨石风化检查:如果该体素被风化移除,跳过生成
if (shouldRemoveByWeathering(ix, y, iz, gobiWeatheringResult)) {
continue; // 跳过生成,风化效果
}
// 3. 确定基础颜色
type = getStoneLayerType(depth, stoneVariantIdx);
// 4. 应用巨石颜色渗透效果
type = applyStoneColorBlending({
currentType: type,
depth,
stoneVariantIdx,
ix,
iz,
y
});
} else if (isInGobiRange) {
const relativeDepth = depth - stoneMicroHeight;
const microDepthFromBottom = gobiMicroHeight - relativeDepth - 1;
// 1. 拱门空洞检查
// 如果存在最小高度限制(用于拱门),且当前层级低于该限制,则跳过生成(形成空洞)
const minLayer = desertContext?.gobiMinHeight[lx][lz] || 0;
const layerFromBottom = Math.floor(microDepthFromBottom / MICRO_SCALE) + 1;
if (layerFromBottom <= minLayer) {
continue; // 跳过生成,形成悬空效果
}
// 2. 风化检查:如果该体素被风化移除,跳过生成
if (shouldRemoveByWeathering(ix, y, iz, gobiWeatheringResult)) {
continue; // 跳过生成,风化效果
}
// 3. 确定基础颜色
const currentLayer = Math.max(1, Math.min(gobiLogicHeight, layerFromBottom));
type = getGobiLayerType(currentLayer, gobiMicroHeight, gobiVariantIdx);
// Note: Internal sand blending removed. External sand mounds will be handled in the main loop.
// 4. 应用戈壁层颜色渗透效果
type = applyGobiColorBlending({
currentType: type,
microDepthFromBottom,
gobiLogicHeight,
gobiMicroHeight,
gobiVariantIdx,
ix,
iz,
y,
MICRO_SCALE,
getGobiLayerType,
});
}
// 4. Sand Mounds at the base (External Sand Blending)
// handled via worldY adjustment earlier
// 根据场景配置或逻辑类型进行分层
// 沙漠场景中,如果当前位置有戈壁或巨石,跳过分层逻辑,保持纯色
if (sceneConfig && !isDesertScene) {
// 使用场景配置的分层逻辑
if (logicType === 'grass' || logicType === 'swamp_grass') {
if (depth > 1) {
type = subSurfaceBlock;
}
} else if (logicType === 'sand' || logicType === 'ash') {
if (depth > 2 && type === logicType) type = subSurfaceBlock;
} else if (logicType === 'snow') {
if (depth > 1 && type === logicType) type = subSurfaceBlock;
if (depth > 3 && type === logicType) type = deepBlock;
} else if (logicType === 'volcanic_rock' || logicType === 'magma_stone') {
if (depth > 2 && type === logicType) type = 'obsidian';
} else if (logicType === 'mud') {
if (depth > 3 && type === logicType) type = deepBlock;
}
} else {
// 更自然的地形分层逻辑(旧逻辑,向后兼容)
// 沙漠场景中,如果当前位置有戈壁或巨石,跳过分层逻辑
if (isDesertScene && (isInGobiRange || isInStoneRange)) {
// 保持戈壁/巨石类型不变,不进行分层
} else if (logicType === 'grass') {
// 草地:临时禁用深色草地变化,统一使用普通草地
// if (depth === 0 && Math.random() > 0.7) {
// type = 'dark_grass_ground';
// } else
if (depth > 1) {
type = 'dirt';
}
} else if (logicType === 'sand') {
// 沙地:沙 -> 泥土 -> 基岩
if (depth > 2) type = 'dirt';
}
}
// 应用泥土分层渐变效果
const noiseInfluence = detailVal; // 使用现有的噪声值
type = applyDirtLayerBlending({
currentType: type,
y,
ix,
iz,
MIN_WORLD_Y,
noiseInfluence,
isDesertScene,
isInGobiRange,
isInStoneRange,
});
// 最底层统一为基岩
if (y <= MIN_WORLD_Y + 1) type = 'bedrock';
const isSurfaceLayer = y === surfaceY && type !== 'bedrock';
const decorationColorShift = isSurfaceLayer ? Math.random() * 0.2 : 0;
// 先使用默认颜色添加体素,稍后处理水体颜色
const color = varyColor(type, detailVal + y * 0.02 + decorationColorShift, 0);
addVoxel(ix, y, iz, type, color, 1);
if (isSurfaceLayer) {
topColumns.set(`${ix}|${iz}`, { index: voxels.length - 1, baseType: logicType });
}
}
if (streamDepthMicro > 0) {
// Rule 1: Enable water filling logic
if (streamInfo?.waterHeight && streamInfo.waterHeight > 0) {
for (let h = 1; h <= streamInfo.waterHeight; h++) {
// Ensure water doesn't exceed surface (should not happen with waterH calculation, but safe)
if (surfaceY + h > surfaceY + streamDepthMicro) break;
addVoxel(
ix,
surfaceY + h,
iz,
'water',
varyColor('water', seededRandom()),
1
);
}
}
}
// Sparse half-height tufts on surface for additional detail
if (
logicType === 'grass' &&
Math.random() > 0.985
) {
addVoxel(
ix,
surfaceY + 1,
iz,
'grass',
varyColor('grass', Math.random()),
0.5
);
}
// 沙漠平面不添加半高沙块,保持平坦
// 所有起伏通过装饰物(石头、石台)实现
// Decorations - 树木成组生成,草丛围绕树木
if (mx === 4 && mz === 4) {
// 旧植被生成逻辑已移除
}
}
}
}
}
// 基础地形生成完成
reportProgress('basic', 100, '基础地形生成完成');
await new Promise(resolve => setTimeout(resolve, 0));
// 开始特征地形阶段(在基础地形循环中已经包含了戈壁、石头等特征)
reportProgress('features', 100, '特征地形生成完成');
await new Promise(resolve => setTimeout(resolve, 0));
// 开始后处理阶段
reportProgress('postprocessing', 0, '开始地貌后处理');
await new Promise(resolve => setTimeout(resolve, 0));
// ===== 水体颜色后处理:基于离陆地距离 =====
// 1. 构建逻辑地形类型映射(基于 topColumns 的 baseType
const logicalTerrainMap = new Map<string, VoxelType>();
topColumns.forEach(({ baseType }, key) => {
logicalTerrainMap.set(key, baseType);
});
// 2. 为每个水体方块计算到最近陆地的距离
voxels.forEach(voxel => {
if (voxel.type === 'water') {
// 转换到逻辑坐标
const logicX = Math.floor(voxel.ix / TILE_SIZE);
const logicZ = Math.floor(voxel.iz / TILE_SIZE);
// BFS 查找最近的陆地(使用逻辑坐标)
let minDistToLand = Infinity;
// 搜索半径(逻辑坐标)- 增加到 12 格
const searchRadius = 12;
for (let dx = -searchRadius; dx <= searchRadius; dx++) {
for (let dz = -searchRadius; dz <= searchRadius; dz++) {
const checkX = logicX + dx;
const checkZ = logicZ + dz;
const checkType = logicalTerrainMap.get(`${checkX}|${checkZ}`);
// 如果是陆地(非水体)
if (checkType && checkType !== 'water') {
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < minDistToLand) {
minDistToLand = dist;
}
}
}
}
// 根据距离重新计算颜色
// 使用平滑的平方根函数:让浅色区域更长,深色只在远处出现
// sqrt(x) 函数增长缓慢,适合创建平滑渐变
const normalizedDist = Math.min(minDistToLand / 12, 1); // 归一化到 0-1
const effectiveDepth = Math.sqrt(normalizedDist) * 10; // 平方根映射到 0-10
// 降低颜色噪声,让渐变更平滑
voxel.color = varyColor('water', Math.random() * 0.1, effectiveDepth);
}
});
reportProgress('postprocessing', 30, '水体颜色处理完成');
await new Promise(resolve => setTimeout(resolve, 0));
// ===== 泥土边界渗透后处理 =====
// (已移除:逻辑已合并至 postprocessing.ts 的 applyDirtLayerBlending 函数中,在生成阶段直接处理)
reportProgress('postprocessing', 60, '泥土渗透处理完成');
await new Promise(resolve => setTimeout(resolve, 0));
// 后处理完成,开始剔除隐藏体素
reportProgress('postprocessing', 70, '开始剔除隐藏体素');
await new Promise(resolve => setTimeout(resolve, 0));
// Cull hidden voxels first
const neighborOffsets = [
[1, 0, 0],
[-1, 0, 0],
[0, 1, 0],
[0, -1, 0],
[0, 0, 1],
[0, 0, -1],
];
const culledVoxels = voxels.filter((voxel) => {
// Always keep bedrock bottom layer to avoid gaps
if (voxel.iy <= MIN_WORLD_Y + 1) return true;
for (const [dx, dy, dz] of neighborOffsets) {
let neighborKey = keyOf(voxel.ix + dx, voxel.iy + dy, voxel.iz + dz);
// 如果是树木体素检查树木专用的key空间
if (voxel.isHighRes) {
neighborKey = `tree_${voxel.ix + dx}|${voxel.iy + dy}|${voxel.iz + dz}`;
}
if (!occupancy.has(neighborKey)) {
return true;
}
}
return false;
});
reportProgress('postprocessing', 90, '隐藏体素剔除完成');
await new Promise(resolve => setTimeout(resolve, 0));
// 开始植被生成阶段(当前版本植被已在基础地形中生成)
reportProgress('vegetation', 0, '开始生成植被');
await new Promise(resolve => setTimeout(resolve, 0));
// 新植被系统集成
// 构建简化的 heightMap
const heightMap = new Map<string, number>();
// topColumns key 是 "ix|iz"value 是 { index, baseType }
// voxels[index].iy 即为最高点的 Y 坐标
topColumns.forEach((data, key) => {
heightMap.set(key, voxels[data.index].iy);
});
const vegContext: VegetationGenerationContext = {
mapSize,
voxels,
occupancy,
treePositions,
seed: seedVal,
desertContext,
isDesertScene,
sceneType: sceneConfig?.name,
heightMap
};
const newPlantVoxels = await generateVegetation(vegContext);
// 关键修复:将植被添加到 culledVoxels (最终返回的数组),而不是 voxels (原始数组)
// 植被体素不需要再次进行剔除检查,直接作为表面装饰添加
culledVoxels.push(...newPlantVoxels);
// Rebuild topColumns map after culling using coordinates
const topColumnsAfterCull = new Map<string, { voxel: VoxelData; baseType: VoxelType }>();
culledVoxels.forEach((voxel) => {
const colKey = `${voxel.ix}|${voxel.iz}`;
const existing = topColumnsAfterCull.get(colKey);
if (!existing || voxel.iy > existing.voxel.iy) {
const original = topColumns.get(colKey);
if (original) {
topColumnsAfterCull.set(colKey, { voxel, baseType: original.baseType });
}
}
});
// Apply surface blend logic based on neighbors AFTER culling
// 临时禁用边界混合逻辑,用于调试
// const neighbor2D = [
// [1, 0],
// [-1, 0],
// [0, 1],
// [0, -1],
// ];
// topColumnsAfterCull.forEach(({ voxel, baseType }) => {
// const rules = BLEND_RULES.filter((r) => r.base === baseType);
// if (!rules.length) return;
// for (const rule of rules) {
// const hasNeighbor = neighbor2D.some(([dx, dz]) => {
// const neighbor = topColumnsAfterCull.get(`${voxel.ix + dx}|${voxel.iz + dz}`);
// return neighbor && neighbor.baseType === rule.neighbor;
// });
// if (hasNeighbor) {
// voxel.type = rule.result as VoxelType;
// voxel.color = varyColor(rule.result as VoxelType, Math.random() * 0.2);
// break;
// }
// }
// });
// 植被生成完成
reportProgress('vegetation', 100, '植被生成完成');
await new Promise(resolve => setTimeout(resolve, 0));
// 最终完成
reportProgress('complete', 100, '地形生成完成');
await new Promise(resolve => setTimeout(resolve, 0));
return culledVoxels;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
import { createNoise2D } from 'simplex-noise';
// 定义微型体素的缩放比例(需与 terrain.ts 保持一致,但为避免循环依赖在此定义或作为参数传入)
const DEFAULT_MICRO_SCALE = 8;
export interface StreamVoxel {
depth: number;
waterHeight: number;
isCenter: boolean;
}
interface Point {
x: number;
y: number; // represents Z in 3D space
}
interface Node {
x: number;
y: number;
f: number;
g: number;
h: number;
parent: Node | null;
}
/**
* 简单的优先队列实现,用于 A* 寻路
*/
class PriorityQueue {
private items: Node[] = [];
enqueue(element: Node) {
let contain = false;
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].f > element.f) {
this.items.splice(i, 0, element);
contain = true;
break;
}
}
if (!contain) {
this.items.push(element);
}
}
dequeue(): Node | undefined {
return this.items.shift();
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
/**
* 生成沙漠溪流系统
* 使用微型体素网格进行 A* 寻路,避开障碍物并引入自然蜿蜒
*
* 更新 V2
* 1. 智能绕过障碍物(加强版寻路)
* 2. 支持从地图任意边缘流向另一边缘,提高出现率
* 3. 保持“宽河床 + 深水道”的立体结构
*
* @param mapSize 逻辑地图大小 (e.g. 16, 32)
* @param gobiHeight 戈壁高度图 (逻辑坐标)
* @param stoneHeight 巨石高度图 (逻辑坐标)
* @param rng 随机数生成器
* @param microScale 微缩比例 (默认 8)
*/
export const generateDesertRiver = (
mapSize: number,
gobiHeight: number[][],
stoneHeight: number[][],
rng: () => number,
microScale: number = DEFAULT_MICRO_SCALE
): Map<string, StreamVoxel> => {
const width = mapSize * microScale;
const height = mapSize * microScale; // height here corresponds to Z axis
// 1. 初始化噪声生成器
let seedVal = rng() * 10000;
const noise2D = createNoise2D(() => {
seedVal = (seedVal * 9301 + 49297) % 233280;
return seedVal / 233280;
});
// 2. 构建障碍物网格 (Collision Grid)
// 0: 空闲, 1: 缓冲区(高代价), 2: 障碍物(不可通行)
const grid = new Uint8Array(width * height).fill(0);
const setGrid = (x: number, z: number, val: number) => {
if (x >= 0 && x < width && z >= 0 && z < height) {
grid[z * width + x] = Math.max(grid[z * width + x], val);
}
};
const getGrid = (x: number, z: number): number => {
if (x >= 0 && x < width && z >= 0 && z < height) {
return grid[z * width + x];
}
return 2; // 越界视为障碍
};
// 填充障碍物数据
for (let lx = 0; lx < mapSize; lx++) {
for (let lz = 0; lz < mapSize; lz++) {
const hasGobi = gobiHeight[lx][lz] > 0;
const hasStone = stoneHeight[lx][lz] > 0;
if (hasGobi || hasStone) {
const startX = lx * microScale;
const startZ = lz * microScale;
// 标记实体障碍
for (let mx = 0; mx < microScale; mx++) {
for (let mz = 0; mz < microScale; mz++) {
setGrid(startX + mx, startZ + mz, 2);
}
}
// 标记缓冲区 (Buffer Zone),设为高代价区域,鼓励河流绕行
// 增加缓冲区范围,确保河流不会贴着山脚
const bufferSize = 8;
for (let bx = -bufferSize; bx < microScale + bufferSize; bx++) {
for (let bz = -bufferSize; bz < microScale + bufferSize; bz++) {
const gx = startX + bx;
const gz = startZ + bz;
if (getGrid(gx, gz) !== 2) {
setGrid(gx, gz, 1);
}
}
}
}
}
}
// 3. A* 寻路尝试循环
// 尝试多次寻找有效路径,直到成功
const maxAttempts = 10;
let bestPath: Node[] | null = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (bestPath) break;
// 随机选择起点和终点边缘 (0: Top, 1: Right, 2: Bottom, 3: Left)
const startEdge = Math.floor(rng() * 4);
let endEdge = Math.floor(rng() * 4);
// 确保终点不在同侧,且尽量不在相邻侧的太近位置
while (endEdge === startEdge) {
endEdge = Math.floor(rng() * 4);
}
const getEdgePoint = (edge: number): Point => {
const margin = 10; // 稍微避开角落
const span = (edge % 2 === 0 ? width : height) - 2 * margin;
const pos = margin + Math.floor(rng() * span);
switch(edge) {
case 0: return { x: pos, y: 0 }; // Top
case 1: return { x: width - 1, y: pos }; // Right
case 2: return { x: pos, y: height - 1 }; // Bottom
case 3: return { x: 0, y: pos }; // Left
default: return { x: 0, y: 0 };
}
};
const startPoint = getEdgePoint(startEdge);
const endPoint = getEdgePoint(endEdge);
// 检查起点终点是否在障碍物内 (虽然边缘通常无障碍,但以防万一)
if (getGrid(startPoint.x, startPoint.y) === 2 || getGrid(endPoint.x, endPoint.y) === 2) {
continue;
}
// 执行 A*
const startNode: Node = { ...startPoint, f: 0, g: 0, h: 0, parent: null };
const openList = new PriorityQueue();
openList.enqueue(startNode);
const closedSet = new Set<string>();
const nodeKey = (x: number, y: number) => `${x}|${y}`;
let finalNode: Node | null = null;
let iterations = 0;
// 增加迭代上限,允许更长的绕路
const maxIterations = width * height * 4;
const heuristic = (x: number, y: number) => Math.abs(endPoint.x - x) + Math.abs(endPoint.y - y);
while (!openList.isEmpty() && iterations < maxIterations) {
iterations++;
const currentNode = openList.dequeue();
if (!currentNode) break;
// 到达终点附近
if (Math.abs(currentNode.x - endPoint.x) < 4 && Math.abs(currentNode.y - endPoint.y) < 4) {
finalNode = currentNode;
break;
}
const key = nodeKey(currentNode.x, currentNode.y);
if (closedSet.has(key)) continue;
closedSet.add(key);
// 8个方向
const dirs = [
{ x: 1, y: 0, cost: 1.0 }, { x: -1, y: 0, cost: 1.0 },
{ x: 0, y: 1, cost: 1.0 }, { x: 0, y: -1, cost: 1.0 },
{ x: 1, y: 1, cost: 1.414 }, { x: 1, y: -1, cost: 1.414 },
{ x: -1, y: 1, cost: 1.414 }, { x: -1, y: -1, cost: 1.414 },
];
for (const dir of dirs) {
const nx = currentNode.x + dir.x;
const ny = currentNode.y + dir.y;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
if (closedSet.has(nodeKey(nx, ny))) continue;
const cellType = getGrid(nx, ny);
if (cellType === 2) continue; // 绝对障碍
let newG = currentNode.g + dir.cost;
// 代价权重调整
if (cellType === 1) {
newG += 50; // 强力避开缓冲区
}
// 噪声代价:引导河流走更自然的路径 (Perlin Noise Flow Field)
// 使用噪声值作为地形阻力,河流倾向于流经 "低阻力" (低噪声值) 区域
const noiseVal = noise2D(nx * 0.03, ny * 0.03);
newG += (noiseVal + 1) * 5; // 增加一些随机地形阻力
newG += rng() * 1.5; // 微小随机抖动
const h = heuristic(nx, ny);
openList.enqueue({
x: nx, y: ny, f: newG + h, g: newG, h: h, parent: currentNode
});
}
}
if (finalNode) {
// 回溯路径
const path: Node[] = [];
let curr: Node | null = finalNode;
while (curr) {
path.push(curr);
curr = curr.parent;
}
bestPath = path;
}
}
const streamMap = new Map<string, StreamVoxel>();
if (bestPath) {
// 4. 渲染宽河床和深水道
// 渲染参数 (保持用户喜欢的立体感)
const channelRadius = 2.0; // 内圈深水道半径 (~4 宽)
const bankRadius = 4.5; // 外圈浅河床半径 (~9 宽),加宽一点
const channelDepth = 3; // 深水道总深度
const bankDepth = 1; // 浅河床深度
const waterFillHeight = 2; // 水深
// 使用 Set 防止重复计算
const processedPoints = new Set<string>();
for (const node of bestPath) {
const cx = node.x;
const cy = node.y; // cy is Z
const scanRad = Math.ceil(bankRadius + 2);
for (let dx = -scanRad; dx <= scanRad; dx++) {
for (let dy = -scanRad; dy <= scanRad; dy++) {
const tx = cx + dx;
const ty = cy + dy;
if (tx < 0 || tx >= width || ty < 0 || ty >= height) continue;
// 绝对避障
if (getGrid(tx, ty) === 2) continue;
const key = `${tx}|${ty}`;
const dist = Math.sqrt(dx*dx + dy*dy);
// 边缘噪声,使河岸不规则 - 降低频率,减少破碎感
const noise = noise2D(tx * 0.08, ty * 0.08) * 0.8; // 0.15 -> 0.08
const noisyChannelRad = channelRadius + noise * 0.5; // 0.6 -> 0.5
const noisyBankRad = bankRadius + noise * 1.0; // 1.2 -> 1.0
let newDepth = 0;
let isCenter = false;
if (dist < noisyChannelRad) {
newDepth = channelDepth;
isCenter = true;
} else if (dist < noisyBankRad) {
newDepth = bankDepth;
}
if (newDepth > 0) {
const existing = streamMap.get(key);
if (!existing || newDepth > existing.depth) {
streamMap.set(key, {
depth: newDepth,
// 只有深水道才放水且水位设置为2 (离地表差1格)
waterHeight: newDepth === channelDepth ? waterFillHeight : 0,
isCenter
});
}
}
}
}
}
console.log(`River generated successfully with length ${bestPath.length}`);
} else {
console.warn("Failed to generate river path after all attempts.");
}
return streamMap;
};

View File

@@ -0,0 +1,123 @@
import { create } from 'zustand';
import { generateTerrain, MAP_SIZES, VOXEL_SIZE, TILE_SIZE, TERRAIN_VERSION, type TerrainGenerationProgress } from './logic/terrain';
import { getRandomSceneType, type SceneType } from './logic/scenes';
import type { VoxelData } from './logic/terrain';
interface MapState {
size: number;
voxels: VoxelData[];
logicalHeightMap: Record<string, number>; // Store logical height for gameplay
terrainVersion: number; // 跟踪地形版本
currentScene: SceneType; // 当前场景类型
isGenerating: boolean; // 是否正在生成地形
generationProgress: TerrainGenerationProgress | null; // 生成进度
// 戈壁风化参数
weatheringMaxIterations: number; // 最大风蚀层数 (1-8)
weatheringHeightBias: number; // 高度保护阈值 (0.0-1.0)
generateMap: (sizeKey: keyof typeof MAP_SIZES, seed?: string, sceneType?: SceneType) => void;
getHeightAt: (x: number, z: number) => number;
setScene: (sceneType: SceneType) => void;
setWeatheringMaxIterations: (value: number) => void;
setWeatheringHeightBias: (value: number) => void;
}
export const useMapStore = create<MapState>((set, get) => ({
size: MAP_SIZES.small,
voxels: [],
logicalHeightMap: {},
terrainVersion: 0,
currentScene: 'desert', // 默认场景:沙漠戈壁(固定用于调试)
isGenerating: false,
generationProgress: null,
// 戈壁风化参数默认值
weatheringMaxIterations: 3,
weatheringHeightBias: 0.5,
generateMap: async (sizeKey, seed, sceneType) => {
// 标记开始生成 - 初始化进度为0%
set({
isGenerating: true,
generationProgress: {
stage: 'basic',
stageLabel: '准备生成地形',
progress: 0,
detail: '正在初始化...'
}
});
const size = MAP_SIZES[sizeKey];
// 如果没有指定场景,使用随机场景
const selectedScene = sceneType || getRandomSceneType();
const runtimeSeed =
seed ??
`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
// 获取当前的风化参数
const { weatheringMaxIterations, weatheringHeightBias } = get();
// 使用 setTimeout 让 React 有时间渲染进度条UI
setTimeout(async () => {
try {
// 使用 await 等待异步地形生成完成,并传递风化参数
const voxels = await generateTerrain(
size,
runtimeSeed,
selectedScene,
(progress) => {
set({ generationProgress: progress });
},
{
weatheringMaxIterations,
weatheringHeightBias
}
);
const logicalHeightMap: Record<string, number> = {};
voxels.forEach((v) => {
const lx = Math.floor(v.x / TILE_SIZE);
const lz = Math.floor(v.z / TILE_SIZE);
const key = `${lx},${lz}`;
if (logicalHeightMap[key] === undefined || v.y > logicalHeightMap[key]) {
logicalHeightMap[key] = v.y;
}
});
set({
size,
voxels,
logicalHeightMap,
terrainVersion: TERRAIN_VERSION,
currentScene: selectedScene,
isGenerating: false,
generationProgress: null
});
} catch (error) {
console.error('地形生成失败:', error);
set({
isGenerating: false,
generationProgress: null
});
}
}, 50); // 给UI 50ms的时间来渲染进度条
},
getHeightAt: (x, z) => {
const key = `${x},${z}`;
const worldY = get().logicalHeightMap[key] || 0;
// Return Logical Y units? No, UnitRenderer expects World Y.
// Our units move in TILE_SIZE steps.
// But their vertical position should be World Y.
// Unit Logic Y:
// If ground is at World Y = 2.5. Unit stands at 2.5 + UnitHeight/2.
// So we return World Y.
return worldY + VOXEL_SIZE; // +1 voxel size to be ON TOP
},
setScene: (sceneType) => {
set({ currentScene: sceneType });
},
setWeatheringMaxIterations: (value) => {
set({ weatheringMaxIterations: value });
},
setWeatheringHeightBias: (value) => {
set({ weatheringHeightBias: value });
}
}));

View File

@@ -0,0 +1,117 @@
import React, { useRef, useMemo, useLayoutEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { Group, Vector3, Color, InstancedMesh, Object3D } from 'three';
import { Html } from '@react-three/drei';
import type { Unit, JobType } from '../types';
import { useUnitStore } from '../store';
import { TILE_SIZE, VOXEL_SIZE } from '../../Map/logic/terrain';
const JOB_COLORS: Record<JobType, string> = {
Squire: '#8B4513',
Chemist: '#ADD8E6',
Knight: '#C0C0C0',
Archer: '#228B22',
Wizard: '#800080',
};
const generateCharacterVoxels = (job: JobType) => {
const voxels = [];
for(let y=0; y<4; y++) {
voxels.push({x: -2, y, z: 0, color: '#333'});
voxels.push({x: 2, y, z: 0, color: '#333'});
}
const bodyColor = JOB_COLORS[job];
for(let y=4; y<10; y++) {
for(let x=-2; x<=2; x++) {
voxels.push({x, y, z: 0, color: bodyColor});
voxels.push({x, y, z: -1, color: bodyColor});
}
}
for(let y=10; y<14; y++) {
for(let x=-2; x<=2; x++) {
voxels.push({x, y, z: 0, color: '#ffe0bd'});
}
}
return voxels;
};
const HighResUnitModel: React.FC<{ unit: Unit; isSelected: boolean }> = ({ unit, isSelected }) => {
const groupRef = useRef<Group>(null);
useFrame(() => {
if (groupRef.current) {
const cx = unit.position.x * TILE_SIZE + TILE_SIZE / 2;
const cz = unit.position.z * TILE_SIZE + TILE_SIZE / 2;
const targetPos = new Vector3(cx, unit.position.y, cz);
groupRef.current.position.lerp(targetPos, 0.1);
const targetRotY = unit.facing * (Math.PI / 2);
groupRef.current.rotation.y = targetRotY;
}
});
const charVoxels = useMemo(() => generateCharacterVoxels(unit.job), [unit.job]);
const meshRef = useRef<InstancedMesh>(null);
const tempObj = new Object3D();
const tempCol = new Color();
useLayoutEffect(() => {
if(!meshRef.current) return;
const s = 0.06;
charVoxels.forEach((v, i) => {
tempObj.position.set(v.x * s, v.y * s, v.z * s);
tempObj.scale.set(s, s, s);
tempObj.updateMatrix();
meshRef.current!.setMatrixAt(i, tempObj.matrix);
tempCol.set(v.color);
meshRef.current!.setColorAt(i, tempCol);
});
meshRef.current.instanceMatrix.needsUpdate = true;
if (meshRef.current.instanceColor) meshRef.current.instanceColor.needsUpdate = true;
}, [charVoxels]);
return (
<group ref={groupRef}>
<instancedMesh ref={meshRef} args={[undefined, undefined, charVoxels.length]} castShadow>
<boxGeometry args={[1,1,1]} />
{/* REMOVED vertexColors={true} here as well */}
<meshStandardMaterial roughness={0.8} metalness={0.1} />
</instancedMesh>
{isSelected && (
<mesh position={[0, 0.1, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.3, 0.35, 32]} />
<meshBasicMaterial color="yellow" transparent opacity={0.8} />
</mesh>
)}
<Html position={[0, 1.2, 0]} center>
<div className="w-8 h-1 bg-gray-700 rounded overflow-hidden border border-black">
<div
className="h-full bg-green-500"
style={{ width: `${(unit.currentHp / unit.maxHp) * 100}%` }}
/>
</div>
</Html>
</group>
);
};
export const UnitRenderer: React.FC = () => {
const units = useUnitStore((state) => state.units);
const selectedUnitId = useUnitStore((state) => state.selectedUnitId);
return (
<group>
{units.map((unit) => (
<HighResUnitModel
key={unit.id}
unit={unit}
isSelected={unit.id === selectedUnitId}
/>
))}
</group>
);
};

View File

@@ -0,0 +1,38 @@
import { create } from 'zustand';
import { BASE_STATS } from './types';
import type { Unit, Direction } from './types';
import { v4 as uuidv4 } from 'uuid';
interface UnitState {
units: Unit[];
selectedUnitId: string | null;
hoveredTile: { x: number; z: number } | null;
addUnit: (unit: Omit<Unit, 'id' | 'currentHp' | 'ct'>) => void;
selectUnit: (id: string | null) => void;
moveUnit: (id: string, to: { x: number; y: number; z: number }) => void;
setHoveredTile: (tile: { x: number; z: number } | null) => void;
}
export const useUnitStore = create<UnitState>((set) => ({
units: [],
selectedUnitId: null,
hoveredTile: null,
addUnit: (unitData) => set((state) => ({
units: [...state.units, {
...unitData,
id: uuidv4(),
currentHp: unitData.maxHp,
ct: 0,
}]
})),
selectUnit: (id) => set({ selectedUnitId: id }),
moveUnit: (id, to) => set((state) => ({
units: state.units.map(u => u.id === id ? { ...u, position: to } : u)
})),
setHoveredTile: (tile) => set({ hoveredTile: tile }),
}));

View File

@@ -0,0 +1,33 @@
export type JobType = 'Squire' | 'Chemist' | 'Knight' | 'Archer' | 'Wizard';
export type Direction = 0 | 1 | 2 | 3; // 0: North (-Z), 1: East (+X), 2: South (+Z), 3: West (-X)
export interface Stats {
move: number;
jump: number;
speed: number;
attack: number;
defense: number;
}
export interface Unit {
id: string;
name: string;
job: JobType;
team: 'player' | 'enemy';
position: { x: number; y: number; z: number };
facing: Direction;
stats: Stats;
currentHp: number;
maxHp: number;
ct: number; // Charge Time (0-100)
}
export const BASE_STATS: Record<JobType, Stats> = {
Squire: { move: 4, jump: 2, speed: 10, attack: 8, defense: 6 },
Chemist: { move: 3, jump: 2, speed: 9, attack: 6, defense: 5 },
Knight: { move: 3, jump: 2, speed: 8, attack: 12, defense: 10 },
Archer: { move: 4, jump: 3, speed: 11, attack: 9, defense: 4 },
Wizard: { move: 3, jump: 2, speed: 9, attack: 14, defense: 3 },
};

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html, body, #root {
@apply w-full h-full m-0 p-0 overflow-hidden bg-[#1a1a1a];
}
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,68 @@
import type { VoxelData } from '../features/Map/logic/terrain';
interface Point { x: number; y: number; z: number }
export const getReachableTiles = (
start: Point,
moveRange: number,
jumpHeight: number,
getHeightAt: (x: number, z: number) => number,
mapSize: number
): Point[] => {
const visited = new Set<string>();
const reachable: Point[] = [];
const queue: { pos: Point; dist: number }[] = [{ pos: start, dist: 0 }];
visited.add(`${start.x},${start.z}`);
reachable.push(start);
while (queue.length > 0) {
const { pos, dist } = queue.shift()!;
if (dist >= moveRange) continue;
const neighbors = [
{ x: pos.x + 1, z: pos.z },
{ x: pos.x - 1, z: pos.z },
{ x: pos.x, z: pos.z + 1 },
{ x: pos.x, z: pos.z - 1 },
];
for (const n of neighbors) {
// Bounds check
if (n.x < 0 || n.z < 0 || n.x >= mapSize || n.z >= mapSize) continue;
const key = `${n.x},${n.z}`;
if (visited.has(key)) continue;
const h = getHeightAt(n.x, n.z);
// const currentH = pos.y + 1; // Logic y is floor.
// const targetH = h; // getHeightAt returns top of block + 1 (unit y).
// Actually, getHeightAt returns (voxel.y + 1).
// Our unit logic stores y as (voxel.y).
// Let's standarize: logic coordinates use "Top of Block Y".
// start.y is Unit Y. getHeightAt returns Unit Y.
// const heightDiff = Math.abs(targetH - start.y); // Compare to START height? No, step by step.
// const stepDiff = Math.abs(targetH - (pos.y || start.y)); // Needs previous step height.
// For simple BFS, we need to know height of current tile 'pos'.
// We didn't store it in queue. Let's fix.
// Actually getHeightAt is cheap O(1).
const myHeight = getHeightAt(pos.x, pos.z);
const nextHeight = getHeightAt(n.x, n.z);
if (Math.abs(nextHeight - myHeight) <= jumpHeight) {
// Valid move (assuming no unit blockage for now)
visited.add(key);
const nextPos = { x: n.x, y: nextHeight - 1, z: n.z }; // Store unit Y (block top)
reachable.push(nextPos);
queue.push({ pos: nextPos, dist: dist + 1 });
}
}
}
return reachable;
};

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})