Initial commit
This commit is contained in:
24
voxel-tactics-horizon/.gitignore
vendored
Normal file
24
voxel-tactics-horizon/.gitignore
vendored
Normal 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?
|
||||
73
voxel-tactics-horizon/README.md
Normal file
73
voxel-tactics-horizon/README.md
Normal 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
voxel-tactics-horizon/eslint.config.js
Normal file
23
voxel-tactics-horizon/eslint.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
voxel-tactics-horizon/index.html
Normal file
13
voxel-tactics-horizon/index.html
Normal 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
5183
voxel-tactics-horizon/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
voxel-tactics-horizon/package.json
Normal file
45
voxel-tactics-horizon/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
voxel-tactics-horizon/postcss.config.js
Normal file
6
voxel-tactics-horizon/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
voxel-tactics-horizon/public/vite.svg
Normal file
1
voxel-tactics-horizon/public/vite.svg
Normal 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 |
42
voxel-tactics-horizon/src/App.css
Normal file
42
voxel-tactics-horizon/src/App.css
Normal 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;
|
||||
}
|
||||
280
voxel-tactics-horizon/src/App.tsx
Normal file
280
voxel-tactics-horizon/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
voxel-tactics-horizon/src/assets/react.svg
Normal file
1
voxel-tactics-horizon/src/assets/react.svg
Normal 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 |
59
voxel-tactics-horizon/src/components/LoadingProgress.tsx
Normal file
59
voxel-tactics-horizon/src/components/LoadingProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
215
voxel-tactics-horizon/src/core/CameraRig.tsx
Normal file
215
voxel-tactics-horizon/src/core/CameraRig.tsx
Normal 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;
|
||||
};
|
||||
133
voxel-tactics-horizon/src/core/EnhancedSky.tsx
Normal file
133
voxel-tactics-horizon/src/core/EnhancedSky.tsx
Normal 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];
|
||||
};
|
||||
|
||||
105
voxel-tactics-horizon/src/core/GameControls.tsx
Normal file
105
voxel-tactics-horizon/src/core/GameControls.tsx
Normal 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)
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
201
voxel-tactics-horizon/src/core/MinecraftSky.tsx
Normal file
201
voxel-tactics-horizon/src/core/MinecraftSky.tsx
Normal 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];
|
||||
};
|
||||
|
||||
120
voxel-tactics-horizon/src/features/Battle/store.ts
Normal file
120
voxel-tactics-horizon/src/features/Battle/store.ts
Normal 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);
|
||||
}
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
41
voxel-tactics-horizon/src/features/Map/components/Cursor.tsx
Normal file
41
voxel-tactics-horizon/src/features/Map/components/Cursor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
605
voxel-tactics-horizon/src/features/Map/logic/desertFeatures.ts
Normal file
605
voxel-tactics-horizon/src/features/Map/logic/desertFeatures.ts
Normal 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';
|
||||
};
|
||||
678
voxel-tactics-horizon/src/features/Map/logic/newVegetation.ts
Normal file
678
voxel-tactics-horizon/src/features/Map/logic/newVegetation.ts
Normal 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;
|
||||
};
|
||||
790
voxel-tactics-horizon/src/features/Map/logic/postprocessing.ts
Normal file
790
voxel-tactics-horizon/src/features/Map/logic/postprocessing.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
296
voxel-tactics-horizon/src/features/Map/logic/scenes.ts
Normal file
296
voxel-tactics-horizon/src/features/Map/logic/scenes.ts
Normal 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];
|
||||
};
|
||||
|
||||
929
voxel-tactics-horizon/src/features/Map/logic/terrain.ts
Normal file
929
voxel-tactics-horizon/src/features/Map/logic/terrain.ts
Normal 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;
|
||||
};
|
||||
2420
voxel-tactics-horizon/src/features/Map/logic/vegetation.ts
Normal file
2420
voxel-tactics-horizon/src/features/Map/logic/vegetation.ts
Normal file
File diff suppressed because it is too large
Load Diff
1100
voxel-tactics-horizon/src/features/Map/logic/voxelStyles.ts
Normal file
1100
voxel-tactics-horizon/src/features/Map/logic/voxelStyles.ts
Normal file
File diff suppressed because it is too large
Load Diff
320
voxel-tactics-horizon/src/features/Map/logic/waterSystem.ts
Normal file
320
voxel-tactics-horizon/src/features/Map/logic/waterSystem.ts
Normal 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;
|
||||
};
|
||||
123
voxel-tactics-horizon/src/features/Map/store.ts
Normal file
123
voxel-tactics-horizon/src/features/Map/store.ts
Normal 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 });
|
||||
}
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
38
voxel-tactics-horizon/src/features/Units/store.ts
Normal file
38
voxel-tactics-horizon/src/features/Units/store.ts
Normal 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 }),
|
||||
}));
|
||||
33
voxel-tactics-horizon/src/features/Units/types.ts
Normal file
33
voxel-tactics-horizon/src/features/Units/types.ts
Normal 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 },
|
||||
};
|
||||
|
||||
9
voxel-tactics-horizon/src/index.css
Normal file
9
voxel-tactics-horizon/src/index.css
Normal 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];
|
||||
}
|
||||
}
|
||||
10
voxel-tactics-horizon/src/main.tsx
Normal file
10
voxel-tactics-horizon/src/main.tsx
Normal 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>,
|
||||
)
|
||||
68
voxel-tactics-horizon/src/utils/pathfinding.ts
Normal file
68
voxel-tactics-horizon/src/utils/pathfinding.ts
Normal 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;
|
||||
};
|
||||
12
voxel-tactics-horizon/tailwind.config.js
Normal file
12
voxel-tactics-horizon/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
28
voxel-tactics-horizon/tsconfig.app.json
Normal file
28
voxel-tactics-horizon/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
voxel-tactics-horizon/tsconfig.json
Normal file
7
voxel-tactics-horizon/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
voxel-tactics-horizon/tsconfig.node.json
Normal file
26
voxel-tactics-horizon/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
7
voxel-tactics-horizon/vite.config.ts
Normal file
7
voxel-tactics-horizon/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user