@lagless/create 0.0.46 → 0.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/pixi-react/CLAUDE.md +3 -0
- package/templates/pixi-react/__packageName__-frontend/package.json +1 -1
- package/templates/pixi-react/__packageName__-frontend/src/app/components/debug-panel.tsx +0 -5
- package/templates/pixi-react/__packageName__-frontend/src/app/game-view/runner-provider.tsx +33 -20
- package/templates/pixi-react/__packageName__-simulation/src/lib/schema/ecs.yaml +0 -18
- package/templates/pixi-react/__packageName__-simulation/src/lib/signals/index.ts +2 -4
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/index.ts +0 -2
- package/templates/pixi-react/docs/09-recipes.md +60 -0
- package/templates/pixi-react/docs/11-2d-map-generation.md +707 -0
- package/templates/pixi-react/__packageName__-simulation/src/lib/systems/hash-verification.system.ts +0 -17
package/dist/index.js
CHANGED
|
@@ -170,6 +170,10 @@ program.name('create-lagless').description('Scaffold a new Lagless multiplayer g
|
|
|
170
170
|
const f = path.join(docsDir, '08-physics3d.md');
|
|
171
171
|
if (fs.existsSync(f)) fs.rmSync(f);
|
|
172
172
|
}
|
|
173
|
+
if (simulationType !== 'physics2d') {
|
|
174
|
+
const f = path.join(docsDir, '11-2d-map-generation.md');
|
|
175
|
+
if (fs.existsSync(f)) fs.rmSync(f);
|
|
176
|
+
}
|
|
173
177
|
// Clone lagless framework source for AI reference
|
|
174
178
|
const sourcesDir = path.join(docsDir, 'sources');
|
|
175
179
|
fs.mkdirSync(sourcesDir, {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { program } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as ejs from 'ejs';\nimport { execSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { select } from '@inquirer/prompts';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Read version from package.json once (single source of truth)\nconst _pkgJsonPath = path.resolve(__dirname, '..', 'package.json');\nlet _packageVersion = '0.0.0';\ntry {\n const _pkg = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf-8'));\n _packageVersion = _pkg.version || _packageVersion;\n} catch {\n // fallback\n}\n\ntype SimulationType = 'raw' | 'physics2d' | 'physics3d';\n\ninterface CreateOptions {\n preset: string;\n port: string;\n serverPort: string;\n simulationType?: SimulationType;\n}\n\nfunction toPascalCase(kebab: string): string {\n return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');\n}\n\nfunction getTemplatesDir(): string {\n const devPath = path.resolve(__dirname, '..', 'templates');\n if (fs.existsSync(devPath)) return devPath;\n const distPath = path.resolve(__dirname, '..', '..', 'templates');\n if (fs.existsSync(distPath)) return distPath;\n throw new Error('Templates directory not found');\n}\n\nfunction walkDir(dir: string): string[] {\n const results: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(full));\n } else {\n results.push(full);\n }\n }\n return results;\n}\n\nfunction processPath(filePath: string, vars: Record<string, string>): string {\n let result = filePath;\n result = result.replace(/__packageName__/g, vars.packageName);\n result = result.replace(/__ProjectName__/g, vars.projectName);\n return result;\n}\n\nasync function promptSimulationType(): Promise<SimulationType> {\n return select<SimulationType>({\n message: 'Select simulation type:',\n choices: [\n { value: 'raw', name: 'Raw ECS', description: 'Manual velocity/position management, no physics engine' },\n { value: 'physics2d', name: 'Physics 2D (Rapier)', description: 'Rapier 2D rigid body physics with auto-managed transforms' },\n { value: 'physics3d', name: 'Physics 3D (Rapier)', description: 'Rapier 3D rigid body physics with top-down 2D rendering' },\n ],\n });\n}\n\nprogram\n .name('create-lagless')\n .description('Scaffold a new Lagless multiplayer game project')\n .version(_packageVersion)\n .argument('<project-name>', 'Project name in kebab-case (e.g., my-game)')\n .option('--preset <preset>', 'Project preset', 'pixi-react')\n .option('--port <port>', 'Frontend dev server port', '4203')\n .option('--server-port <port>', 'Backend server port', '3400')\n .option('--simulation-type <type>', 'Simulation type: raw, physics2d, or physics3d')\n .action(async (projectArg: string, options: CreateOptions) => {\n const targetDir = path.resolve(process.cwd(), projectArg);\n const packageName = path.basename(targetDir).toLowerCase();\n const pascalName = toPascalCase(packageName);\n\n if (fs.existsSync(targetDir)) {\n console.error(`Error: Directory \"${targetDir}\" already exists.`);\n process.exit(1);\n }\n\n const templatesDir = getTemplatesDir();\n const presetDir = path.join(templatesDir, options.preset);\n\n if (!fs.existsSync(presetDir)) {\n console.error(`Error: Preset \"${options.preset}\" not found. Available: ${fs.readdirSync(templatesDir).join(', ')}`);\n process.exit(1);\n }\n\n // Determine simulation type — from CLI flag or interactive prompt\n let simulationType: SimulationType;\n if (options.simulationType) {\n const valid: SimulationType[] = ['raw', 'physics2d', 'physics3d'];\n if (!valid.includes(options.simulationType as SimulationType)) {\n console.error(`Error: Invalid simulation type \"${options.simulationType}\". Valid: ${valid.join(', ')}`);\n process.exit(1);\n }\n simulationType = options.simulationType as SimulationType;\n } else {\n simulationType = await promptSimulationType();\n }\n\n const laglessVersion = _packageVersion;\n\n const vars: Record<string, string> = {\n projectName: pascalName,\n packageName,\n frontendPort: options.port,\n serverPort: options.serverPort,\n laglessVersion,\n simulationType,\n };\n\n console.log(`\\nCreating Lagless project \"${packageName}\"...`);\n console.log(` Preset: ${options.preset}`);\n console.log(` Simulation: ${simulationType}`);\n console.log(` Frontend port: ${options.port}`);\n console.log(` Server port: ${options.serverPort}`);\n console.log(` Target: ${targetDir}\\n`);\n\n const templateFiles = walkDir(presetDir);\n\n for (const templateFile of templateFiles) {\n const relativePath = path.relative(presetDir, templateFile);\n const outputRelative = processPath(relativePath, vars);\n const outputPath = path.join(targetDir, outputRelative);\n const outputDir = path.dirname(outputPath);\n\n const content = fs.readFileSync(templateFile, 'utf-8');\n\n // Only process text files that might contain EJS tags\n const ext = path.extname(templateFile);\n const textExts = ['.ts', '.tsx', '.json', '.yaml', '.yml', '.html', '.css', '.md', '.toml', '.gitignore'];\n\n if (textExts.includes(ext) || ext === '.ejs') {\n const rendered = ejs.render(content, vars, { filename: templateFile });\n const finalPath = ext === '.ejs' ? outputPath.replace(/\\.ejs$/, '') : outputPath;\n\n // Skip writing empty/whitespace-only files (conditional template exclusion)\n if (rendered.trim().length === 0) {\n continue;\n }\n\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.writeFileSync(finalPath, rendered, 'utf-8');\n } else {\n // Binary or unknown — copy as-is\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.copyFileSync(templateFile, outputPath);\n }\n }\n\n // Remove physics docs that don't match the selected simulationType\n const docsDir = path.join(targetDir, 'docs');\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '08-physics2d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics3d') {\n const f = path.join(docsDir, '08-physics3d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n\n // Clone lagless framework source for AI reference\n const sourcesDir = path.join(docsDir, 'sources');\n fs.mkdirSync(sourcesDir, { recursive: true });\n console.log('Cloning lagless framework source for AI reference...');\n try {\n execSync(`git clone --depth 1 https://github.com/GbGr/lagless.git \"${path.join(sourcesDir, 'lagless')}\"`, {\n stdio: 'inherit',\n });\n // Remove .git to save space\n fs.rmSync(path.join(sourcesDir, 'lagless', '.git'), { recursive: true, force: true });\n } catch {\n console.warn('Warning: Could not clone lagless source. AI reference will be unavailable.');\n console.warn('You can manually clone later: git clone --depth 1 https://github.com/GbGr/lagless.git docs/sources/lagless');\n }\n\n // Install dependencies\n console.log('\\nInstalling dependencies...');\n try {\n execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: pnpm install failed. Run it manually after creation.');\n }\n\n // Run ECS codegen to generate code from schema\n console.log('\\nGenerating ECS code from schema...');\n try {\n execSync('pnpm codegen', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: codegen failed. Run \"pnpm codegen\" manually after creation.');\n }\n\n console.log('\\nProject created successfully!\\n');\n console.log('To start developing:');\n console.log(` cd ${packageName}`);\n console.log(' pnpm dev # Start backend + frontend + dev-player\\n');\n console.log('Or run individually:');\n console.log(' pnpm dev:backend # Game server (Bun, watches for changes)');\n console.log(' pnpm dev:frontend # Frontend (Vite HMR)');\n console.log(' pnpm dev:player # Dev-player (multiplayer testing, port 4210)\\n');\n console.log('To regenerate ECS code after schema changes:');\n console.log(' pnpm codegen\\n');\n });\n\nprogram.parse();\n"],"names":["program","fs","path","ejs","execSync","fileURLToPath","select","__filename","url","__dirname","dirname","_pkgJsonPath","resolve","_packageVersion","_pkg","JSON","parse","readFileSync","version","toPascalCase","kebab","split","map","s","charAt","toUpperCase","slice","join","getTemplatesDir","devPath","existsSync","distPath","Error","walkDir","dir","results","entry","readdirSync","withFileTypes","full","name","isDirectory","push","processPath","filePath","vars","result","replace","packageName","projectName","promptSimulationType","message","choices","value","description","argument","option","action","projectArg","options","targetDir","process","cwd","basename","toLowerCase","pascalName","console","error","exit","templatesDir","presetDir","preset","simulationType","valid","includes","laglessVersion","frontendPort","port","serverPort","log","templateFiles","templateFile","relativePath","relative","outputRelative","outputPath","outputDir","content","ext","extname","textExts","rendered","render","filename","finalPath","trim","length","mkdirSync","recursive","writeFileSync","copyFileSync","docsDir","f","rmSync","sourcesDir","stdio","force","warn"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { program } from 'commander';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as ejs from 'ejs';\nimport { execSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { select } from '@inquirer/prompts';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n// Read version from package.json once (single source of truth)\nconst _pkgJsonPath = path.resolve(__dirname, '..', 'package.json');\nlet _packageVersion = '0.0.0';\ntry {\n const _pkg = JSON.parse(fs.readFileSync(_pkgJsonPath, 'utf-8'));\n _packageVersion = _pkg.version || _packageVersion;\n} catch {\n // fallback\n}\n\ntype SimulationType = 'raw' | 'physics2d' | 'physics3d';\n\ninterface CreateOptions {\n preset: string;\n port: string;\n serverPort: string;\n simulationType?: SimulationType;\n}\n\nfunction toPascalCase(kebab: string): string {\n return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('');\n}\n\nfunction getTemplatesDir(): string {\n const devPath = path.resolve(__dirname, '..', 'templates');\n if (fs.existsSync(devPath)) return devPath;\n const distPath = path.resolve(__dirname, '..', '..', 'templates');\n if (fs.existsSync(distPath)) return distPath;\n throw new Error('Templates directory not found');\n}\n\nfunction walkDir(dir: string): string[] {\n const results: string[] = [];\n for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {\n const full = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n results.push(...walkDir(full));\n } else {\n results.push(full);\n }\n }\n return results;\n}\n\nfunction processPath(filePath: string, vars: Record<string, string>): string {\n let result = filePath;\n result = result.replace(/__packageName__/g, vars.packageName);\n result = result.replace(/__ProjectName__/g, vars.projectName);\n return result;\n}\n\nasync function promptSimulationType(): Promise<SimulationType> {\n return select<SimulationType>({\n message: 'Select simulation type:',\n choices: [\n { value: 'raw', name: 'Raw ECS', description: 'Manual velocity/position management, no physics engine' },\n { value: 'physics2d', name: 'Physics 2D (Rapier)', description: 'Rapier 2D rigid body physics with auto-managed transforms' },\n { value: 'physics3d', name: 'Physics 3D (Rapier)', description: 'Rapier 3D rigid body physics with top-down 2D rendering' },\n ],\n });\n}\n\nprogram\n .name('create-lagless')\n .description('Scaffold a new Lagless multiplayer game project')\n .version(_packageVersion)\n .argument('<project-name>', 'Project name in kebab-case (e.g., my-game)')\n .option('--preset <preset>', 'Project preset', 'pixi-react')\n .option('--port <port>', 'Frontend dev server port', '4203')\n .option('--server-port <port>', 'Backend server port', '3400')\n .option('--simulation-type <type>', 'Simulation type: raw, physics2d, or physics3d')\n .action(async (projectArg: string, options: CreateOptions) => {\n const targetDir = path.resolve(process.cwd(), projectArg);\n const packageName = path.basename(targetDir).toLowerCase();\n const pascalName = toPascalCase(packageName);\n\n if (fs.existsSync(targetDir)) {\n console.error(`Error: Directory \"${targetDir}\" already exists.`);\n process.exit(1);\n }\n\n const templatesDir = getTemplatesDir();\n const presetDir = path.join(templatesDir, options.preset);\n\n if (!fs.existsSync(presetDir)) {\n console.error(`Error: Preset \"${options.preset}\" not found. Available: ${fs.readdirSync(templatesDir).join(', ')}`);\n process.exit(1);\n }\n\n // Determine simulation type — from CLI flag or interactive prompt\n let simulationType: SimulationType;\n if (options.simulationType) {\n const valid: SimulationType[] = ['raw', 'physics2d', 'physics3d'];\n if (!valid.includes(options.simulationType as SimulationType)) {\n console.error(`Error: Invalid simulation type \"${options.simulationType}\". Valid: ${valid.join(', ')}`);\n process.exit(1);\n }\n simulationType = options.simulationType as SimulationType;\n } else {\n simulationType = await promptSimulationType();\n }\n\n const laglessVersion = _packageVersion;\n\n const vars: Record<string, string> = {\n projectName: pascalName,\n packageName,\n frontendPort: options.port,\n serverPort: options.serverPort,\n laglessVersion,\n simulationType,\n };\n\n console.log(`\\nCreating Lagless project \"${packageName}\"...`);\n console.log(` Preset: ${options.preset}`);\n console.log(` Simulation: ${simulationType}`);\n console.log(` Frontend port: ${options.port}`);\n console.log(` Server port: ${options.serverPort}`);\n console.log(` Target: ${targetDir}\\n`);\n\n const templateFiles = walkDir(presetDir);\n\n for (const templateFile of templateFiles) {\n const relativePath = path.relative(presetDir, templateFile);\n const outputRelative = processPath(relativePath, vars);\n const outputPath = path.join(targetDir, outputRelative);\n const outputDir = path.dirname(outputPath);\n\n const content = fs.readFileSync(templateFile, 'utf-8');\n\n // Only process text files that might contain EJS tags\n const ext = path.extname(templateFile);\n const textExts = ['.ts', '.tsx', '.json', '.yaml', '.yml', '.html', '.css', '.md', '.toml', '.gitignore'];\n\n if (textExts.includes(ext) || ext === '.ejs') {\n const rendered = ejs.render(content, vars, { filename: templateFile });\n const finalPath = ext === '.ejs' ? outputPath.replace(/\\.ejs$/, '') : outputPath;\n\n // Skip writing empty/whitespace-only files (conditional template exclusion)\n if (rendered.trim().length === 0) {\n continue;\n }\n\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.writeFileSync(finalPath, rendered, 'utf-8');\n } else {\n // Binary or unknown — copy as-is\n if (!fs.existsSync(outputDir)) {\n fs.mkdirSync(outputDir, { recursive: true });\n }\n fs.copyFileSync(templateFile, outputPath);\n }\n }\n\n // Remove physics docs that don't match the selected simulationType\n const docsDir = path.join(targetDir, 'docs');\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '08-physics2d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics3d') {\n const f = path.join(docsDir, '08-physics3d.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n if (simulationType !== 'physics2d') {\n const f = path.join(docsDir, '11-2d-map-generation.md');\n if (fs.existsSync(f)) fs.rmSync(f);\n }\n\n // Clone lagless framework source for AI reference\n const sourcesDir = path.join(docsDir, 'sources');\n fs.mkdirSync(sourcesDir, { recursive: true });\n console.log('Cloning lagless framework source for AI reference...');\n try {\n execSync(`git clone --depth 1 https://github.com/GbGr/lagless.git \"${path.join(sourcesDir, 'lagless')}\"`, {\n stdio: 'inherit',\n });\n // Remove .git to save space\n fs.rmSync(path.join(sourcesDir, 'lagless', '.git'), { recursive: true, force: true });\n } catch {\n console.warn('Warning: Could not clone lagless source. AI reference will be unavailable.');\n console.warn('You can manually clone later: git clone --depth 1 https://github.com/GbGr/lagless.git docs/sources/lagless');\n }\n\n // Install dependencies\n console.log('\\nInstalling dependencies...');\n try {\n execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: pnpm install failed. Run it manually after creation.');\n }\n\n // Run ECS codegen to generate code from schema\n console.log('\\nGenerating ECS code from schema...');\n try {\n execSync('pnpm codegen', { cwd: targetDir, stdio: 'inherit' });\n } catch {\n console.error('Warning: codegen failed. Run \"pnpm codegen\" manually after creation.');\n }\n\n console.log('\\nProject created successfully!\\n');\n console.log('To start developing:');\n console.log(` cd ${packageName}`);\n console.log(' pnpm dev # Start backend + frontend + dev-player\\n');\n console.log('Or run individually:');\n console.log(' pnpm dev:backend # Game server (Bun, watches for changes)');\n console.log(' pnpm dev:frontend # Frontend (Vite HMR)');\n console.log(' pnpm dev:player # Dev-player (multiplayer testing, port 4210)\\n');\n console.log('To regenerate ECS code after schema changes:');\n console.log(' pnpm codegen\\n');\n });\n\nprogram.parse();\n"],"names":["program","fs","path","ejs","execSync","fileURLToPath","select","__filename","url","__dirname","dirname","_pkgJsonPath","resolve","_packageVersion","_pkg","JSON","parse","readFileSync","version","toPascalCase","kebab","split","map","s","charAt","toUpperCase","slice","join","getTemplatesDir","devPath","existsSync","distPath","Error","walkDir","dir","results","entry","readdirSync","withFileTypes","full","name","isDirectory","push","processPath","filePath","vars","result","replace","packageName","projectName","promptSimulationType","message","choices","value","description","argument","option","action","projectArg","options","targetDir","process","cwd","basename","toLowerCase","pascalName","console","error","exit","templatesDir","presetDir","preset","simulationType","valid","includes","laglessVersion","frontendPort","port","serverPort","log","templateFiles","templateFile","relativePath","relative","outputRelative","outputPath","outputDir","content","ext","extname","textExts","rendered","render","filename","finalPath","trim","length","mkdirSync","recursive","writeFileSync","copyFileSync","docsDir","f","rmSync","sourcesDir","stdio","force","warn"],"rangeMappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;","mappings":"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"}
|
package/package.json
CHANGED
|
@@ -240,6 +240,9 @@ Systems run in array order every tick. Canonical ordering:
|
|
|
240
240
|
<% } else if (simulationType === 'physics3d') { -%>
|
|
241
241
|
| [docs/08-physics3d.md](docs/08-physics3d.md) | Rapier 3D integration, character controller, animation |
|
|
242
242
|
<% } -%>
|
|
243
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
244
|
+
| [docs/11-2d-map-generation.md](docs/11-2d-map-generation.md) | Procedural 2D map generation, terrain rendering, object placement |
|
|
245
|
+
<% } -%>
|
|
243
246
|
| [docs/09-recipes.md](docs/09-recipes.md) | Step-by-step cookbook for common game features |
|
|
244
247
|
| [docs/10-common-mistakes.md](docs/10-common-mistakes.md) | "Never do X" reference + error solutions |
|
|
245
248
|
| [docs/api-quick-reference.md](docs/api-quick-reference.md) | One-page API cheat sheet |
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<% if (simulationType === 'physics2d') { -%>
|
|
21
21
|
"@lagless/physics2d": "^<%= laglessVersion %>",
|
|
22
22
|
"@lagless/physics-shared": "^<%= laglessVersion %>",
|
|
23
|
-
"@
|
|
23
|
+
"@lagless/rapier2d-deterministic-compat": "^0.19.0",
|
|
24
24
|
<% } else if (simulationType === 'physics3d') { -%>
|
|
25
25
|
"@lagless/physics3d": "^<%= laglessVersion %>",
|
|
26
26
|
"@lagless/physics-shared": "^<%= laglessVersion %>",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { FC } from 'react';
|
|
2
2
|
import { useRunner } from '../game-view/runner-provider';
|
|
3
3
|
import { DebugPanel as SharedDebugPanel } from '@lagless/react';
|
|
4
|
-
import { PlayerResource, DivergenceSignal } from '<%= packageName %>-simulation';
|
|
5
4
|
|
|
6
5
|
export const DebugPanel: FC = () => {
|
|
7
6
|
const runner = useRunner();
|
|
@@ -9,10 +8,6 @@ export const DebugPanel: FC = () => {
|
|
|
9
8
|
return (
|
|
10
9
|
<SharedDebugPanel
|
|
11
10
|
runner={runner}
|
|
12
|
-
hashVerification={{
|
|
13
|
-
playerResourceClass: PlayerResource,
|
|
14
|
-
divergenceSignalClass: DivergenceSignal,
|
|
15
|
-
}}
|
|
16
11
|
/>
|
|
17
12
|
);
|
|
18
13
|
};
|
|
@@ -2,13 +2,11 @@ import {
|
|
|
2
2
|
<%= projectName %>Runner,
|
|
3
3
|
<%= projectName %>Systems,
|
|
4
4
|
<%= projectName %>Signals,
|
|
5
|
-
DivergenceSignal,
|
|
6
5
|
MoveInput,
|
|
7
6
|
PlayerJoined,
|
|
8
|
-
ReportHash,
|
|
9
7
|
<%= projectName %>Arena,
|
|
10
8
|
} from '<%= packageName %>-simulation';
|
|
11
|
-
import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react';
|
|
9
|
+
import { createContext, FC, ReactNode, useContext, useEffect, useRef, useState } from 'react';
|
|
12
10
|
import { useTick } from '@pixi/react';
|
|
13
11
|
import { useNavigate } from 'react-router-dom';
|
|
14
12
|
import { ProviderStore } from '../hooks/use-start-match';
|
|
@@ -16,7 +14,7 @@ import { ECSConfig, LocalInputProvider, RPC, createHashReporter } from '@lagless
|
|
|
16
14
|
import { RelayInputProvider, RelayConnection } from '@lagless/relay-client';
|
|
17
15
|
import { getMatchInfo } from '../hooks/use-start-multiplayer-match';
|
|
18
16
|
import { UUID } from '@lagless/misc';
|
|
19
|
-
import { useDevBridge } from '@lagless/react';
|
|
17
|
+
import { useDevBridge, useDiagnosticsControl } from '@lagless/react';
|
|
20
18
|
<% if (simulationType === 'physics2d') { -%>
|
|
21
19
|
import { PhysicsWorldManager2d, type RapierModule2d } from '@lagless/physics2d';
|
|
22
20
|
<% } else if (simulationType === 'physics3d') { -%>
|
|
@@ -41,6 +39,9 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
41
39
|
const [runner, setRunner] = useState<<%= projectName %>Runner>(null!);
|
|
42
40
|
const [v, setV] = useState(0);
|
|
43
41
|
const navigate = useNavigate();
|
|
42
|
+
const diagnosticsEnabled = useDiagnosticsControl();
|
|
43
|
+
const hashReporterRef = useRef<ReturnType<typeof createHashReporter> | null>(null);
|
|
44
|
+
const connectionRef = useRef<RelayConnection | null>(null);
|
|
44
45
|
|
|
45
46
|
useEffect(() => {
|
|
46
47
|
return ProviderStore.onProvider(() => {
|
|
@@ -75,7 +76,7 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
75
76
|
<% if (simulationType === 'physics2d') { -%>
|
|
76
77
|
// Load Rapier 2D WASM
|
|
77
78
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
-
const RAPIER = (await import('@
|
|
79
|
+
const RAPIER = (await import('@lagless/rapier2d-deterministic-compat')).default as any;
|
|
79
80
|
await RAPIER.init();
|
|
80
81
|
const rapier = RAPIER as unknown as RapierModule2d;
|
|
81
82
|
if (disposed) { inputProvider.dispose(); return; }
|
|
@@ -105,11 +106,13 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
105
106
|
onPong: (data) => inputProvider.handlePong(data),
|
|
106
107
|
onStateRequest: (requestId) => inputProvider.handleStateRequest(requestId),
|
|
107
108
|
onStateResponse: (data) => inputProvider.handleStateResponse(data),
|
|
109
|
+
onHashMismatch: (data) => hashReporterRef.current?.reportMismatch(data),
|
|
108
110
|
onConnected: () => console.log('[Relay] Connected'),
|
|
109
111
|
onDisconnected: () => console.log('[Relay] Disconnected'),
|
|
110
112
|
},
|
|
111
113
|
);
|
|
112
114
|
|
|
115
|
+
connectionRef.current = _connection;
|
|
113
116
|
inputProvider.setConnection(_connection);
|
|
114
117
|
_connection.connect();
|
|
115
118
|
|
|
@@ -137,12 +140,6 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
137
140
|
<% } -%>
|
|
138
141
|
}
|
|
139
142
|
|
|
140
|
-
// Set up keyboard input drainer with hash reporting
|
|
141
|
-
const reportHash = createHashReporter(_runner, {
|
|
142
|
-
reportInterval: <%= projectName %>Arena.hashReportInterval,
|
|
143
|
-
reportHashRpc: ReportHash,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
143
|
inputProvider.drainInputs((addRPC) => {
|
|
147
144
|
let dx = 0;
|
|
148
145
|
let dy = 0;
|
|
@@ -158,11 +155,8 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
158
155
|
}
|
|
159
156
|
addRPC(MoveInput, { directionX: dx, directionY: dy });
|
|
160
157
|
}
|
|
161
|
-
|
|
162
|
-
reportHash(addRPC);
|
|
163
158
|
});
|
|
164
159
|
|
|
165
|
-
_runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
|
|
166
160
|
_runner.start();
|
|
167
161
|
|
|
168
162
|
if (inputProvider instanceof RelayInputProvider) {
|
|
@@ -186,11 +180,6 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
186
180
|
inputProvider.addRemoteRpc(joinRpc);
|
|
187
181
|
}
|
|
188
182
|
|
|
189
|
-
const divergenceSignal = _runner.DIContainer.resolve(DivergenceSignal);
|
|
190
|
-
divergenceSignal.Predicted.subscribe((e) => {
|
|
191
|
-
console.warn(`[DIVERGENCE] Players ${e.data.slotA} vs ${e.data.slotB}: hash ${e.data.hashA} != ${e.data.hashB} at tick ${e.data.atTick}`);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
183
|
setRunner(_runner);
|
|
195
184
|
})();
|
|
196
185
|
|
|
@@ -198,12 +187,36 @@ export const RunnerProvider: FC<RunnerProviderProps> = ({ children }) => {
|
|
|
198
187
|
disposed = true;
|
|
199
188
|
window.removeEventListener('keydown', onKeyDown);
|
|
200
189
|
window.removeEventListener('keyup', onKeyUp);
|
|
190
|
+
connectionRef.current = null;
|
|
201
191
|
_connection?.disconnect();
|
|
202
192
|
_runner?.dispose();
|
|
203
193
|
};
|
|
204
194
|
}, [v, navigate]);
|
|
205
195
|
|
|
206
|
-
|
|
196
|
+
// Diagnostics lifecycle: enable/disable hash tracking and hash reporter based on toggle
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!runner || !diagnosticsEnabled) {
|
|
199
|
+
runner?.Simulation.disableHashTracking();
|
|
200
|
+
hashReporterRef.current?.dispose();
|
|
201
|
+
hashReporterRef.current = null;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
runner.Simulation.enableHashTracking(<%= projectName %>Arena.hashReportInterval);
|
|
205
|
+
const reporter = createHashReporter(runner, {
|
|
206
|
+
reportInterval: <%= projectName %>Arena.hashReportInterval,
|
|
207
|
+
send: (data) => connectionRef.current?.sendHashReport(data),
|
|
208
|
+
});
|
|
209
|
+
reporter.subscribeDivergence((data) => {
|
|
210
|
+
console.warn(`[DIVERGENCE] Players ${data.slotA} vs ${data.slotB}: hash ${data.hashA} != ${data.hashB} at tick ${data.atTick}`);
|
|
211
|
+
});
|
|
212
|
+
hashReporterRef.current = reporter;
|
|
213
|
+
return () => {
|
|
214
|
+
reporter.dispose();
|
|
215
|
+
hashReporterRef.current = null;
|
|
216
|
+
};
|
|
217
|
+
}, [runner, diagnosticsEnabled]);
|
|
218
|
+
|
|
219
|
+
useDevBridge(runner, { hashTrackingInterval: <%= projectName %>Arena.hashReportInterval, diagnosticsEnabled });
|
|
207
220
|
|
|
208
221
|
return !runner ? null : <RunnerContext.Provider value={runner}>{children}</RunnerContext.Provider>;
|
|
209
222
|
};
|
|
@@ -24,9 +24,6 @@ playerResources:
|
|
|
24
24
|
id: uint8[16]
|
|
25
25
|
entity: uint32
|
|
26
26
|
connected: uint8
|
|
27
|
-
lastReportedHash: uint32
|
|
28
|
-
lastReportedHashTick: uint32
|
|
29
|
-
hashMismatchCount: uint16
|
|
30
27
|
|
|
31
28
|
inputs:
|
|
32
29
|
PlayerJoined:
|
|
@@ -38,9 +35,6 @@ inputs:
|
|
|
38
35
|
MoveInput:
|
|
39
36
|
directionX: float32
|
|
40
37
|
directionY: float32
|
|
41
|
-
ReportHash:
|
|
42
|
-
hash: uint32
|
|
43
|
-
atTick: uint32
|
|
44
38
|
|
|
45
39
|
filters:
|
|
46
40
|
PlayerFilter:
|
|
@@ -69,9 +63,6 @@ playerResources:
|
|
|
69
63
|
id: uint8[16]
|
|
70
64
|
entity: uint32
|
|
71
65
|
connected: uint8
|
|
72
|
-
lastReportedHash: uint32
|
|
73
|
-
lastReportedHashTick: uint32
|
|
74
|
-
hashMismatchCount: uint16
|
|
75
66
|
|
|
76
67
|
inputs:
|
|
77
68
|
PlayerJoined:
|
|
@@ -83,9 +74,6 @@ inputs:
|
|
|
83
74
|
MoveInput:
|
|
84
75
|
directionX: float32
|
|
85
76
|
directionY: float32
|
|
86
|
-
ReportHash:
|
|
87
|
-
hash: uint32
|
|
88
|
-
atTick: uint32
|
|
89
77
|
|
|
90
78
|
filters:
|
|
91
79
|
PlayerFilter:
|
|
@@ -112,9 +100,6 @@ playerResources:
|
|
|
112
100
|
id: uint8[16]
|
|
113
101
|
entity: uint32
|
|
114
102
|
connected: uint8
|
|
115
|
-
lastReportedHash: uint32
|
|
116
|
-
lastReportedHashTick: uint32
|
|
117
|
-
hashMismatchCount: uint16
|
|
118
103
|
|
|
119
104
|
inputs:
|
|
120
105
|
PlayerJoined:
|
|
@@ -126,9 +111,6 @@ inputs:
|
|
|
126
111
|
MoveInput:
|
|
127
112
|
directionX: float32
|
|
128
113
|
directionY: float32
|
|
129
|
-
ReportHash:
|
|
130
|
-
hash: uint32
|
|
131
|
-
atTick: uint32
|
|
132
114
|
|
|
133
115
|
filters:
|
|
134
116
|
PlayerFilter:
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { ISignalConstructor
|
|
1
|
+
import { ISignalConstructor } from '@lagless/core';
|
|
2
2
|
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
export const <%= projectName %>Signals: ISignalConstructor[] = [DivergenceSignal];
|
|
3
|
+
export const <%= projectName %>Signals: ISignalConstructor[] = [];
|
|
@@ -10,7 +10,6 @@ import { BoundarySystem } from './boundary.system.js';
|
|
|
10
10
|
<% } else { -%>
|
|
11
11
|
import { PhysicsStepSystem } from './physics-step.system.js';
|
|
12
12
|
<% } -%>
|
|
13
|
-
import { HashVerificationSystem } from './hash-verification.system.js';
|
|
14
13
|
|
|
15
14
|
export const <%= projectName %>Systems: IECSSystemConstructor[] = [
|
|
16
15
|
SavePrevTransformSystem,
|
|
@@ -24,5 +23,4 @@ export const <%= projectName %>Systems: IECSSystemConstructor[] = [
|
|
|
24
23
|
<% } else { -%>
|
|
25
24
|
PhysicsStepSystem,
|
|
26
25
|
<% } -%>
|
|
27
|
-
HashVerificationSystem,
|
|
28
26
|
];
|
|
@@ -360,3 +360,63 @@ export class ProjectileLifetimeSystem implements IECSSystem {
|
|
|
360
360
|
this._arenaConfig.radius -= this._arenaConfig.shrinkRate;
|
|
361
361
|
}
|
|
362
362
|
```
|
|
363
|
+
<% if (simulationType === 'physics2d') { -%>
|
|
364
|
+
|
|
365
|
+
## Add Procedural 2D Map
|
|
366
|
+
|
|
367
|
+
1. **Install packages:**
|
|
368
|
+
```bash
|
|
369
|
+
pnpm add @lagless/2d-map-generator @lagless/2d-map-renderer
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
2. **Define object types** in `simulation/src/lib/map-config/objects.ts`:
|
|
373
|
+
```typescript
|
|
374
|
+
import type { MapObjectDef, MapObjectRegistry } from '@lagless/2d-map-generator';
|
|
375
|
+
import { RenderLayer, ShapeType, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
|
|
376
|
+
|
|
377
|
+
export enum ObjectType { Tree = 0 }
|
|
378
|
+
|
|
379
|
+
const TREE: MapObjectDef = {
|
|
380
|
+
typeId: ObjectType.Tree,
|
|
381
|
+
colliders: [
|
|
382
|
+
{ shape: { type: ShapeType.Circle, radius: 30 } },
|
|
383
|
+
{ shape: { type: ShapeType.Circle, radius: 128 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
|
|
384
|
+
],
|
|
385
|
+
visuals: [
|
|
386
|
+
{ texture: 'tree-trunk', layer: RenderLayer.Ground },
|
|
387
|
+
{ texture: 'tree-foliage', layer: RenderLayer.Canopy },
|
|
388
|
+
],
|
|
389
|
+
scaleRange: [0.1, 0.2],
|
|
390
|
+
includeSensorsInBounds: true,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
export const OBJECT_REGISTRY: MapObjectRegistry = new Map([[0, TREE]]);
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
3. **Create generator factory** in `simulation/src/lib/map-config/create-map-generator.ts`:
|
|
397
|
+
```typescript
|
|
398
|
+
import { MapGenerator, BiomeFeature, ShoreFeature, GrassFeature,
|
|
399
|
+
ObjectPlacementFeature, PlacementKind, TerrainZone, STANDARD_BIOME,
|
|
400
|
+
} from '@lagless/2d-map-generator';
|
|
401
|
+
import { OBJECT_REGISTRY, ObjectType } from './objects.js';
|
|
402
|
+
|
|
403
|
+
export function createMapGenerator(): MapGenerator {
|
|
404
|
+
return new MapGenerator({ baseWidth: 720, baseHeight: 720, scale: 1, extension: 80, gridSize: 16 })
|
|
405
|
+
.addFeature(new BiomeFeature(), STANDARD_BIOME)
|
|
406
|
+
.addFeature(new ShoreFeature(), { inset: 48, divisions: 12, variation: 4 })
|
|
407
|
+
.addFeature(new GrassFeature(), { inset: 18, variation: 3 })
|
|
408
|
+
.addFeature(new ObjectPlacementFeature(), {
|
|
409
|
+
registry: OBJECT_REGISTRY,
|
|
410
|
+
stages: [{ kind: PlacementKind.Density, typeId: ObjectType.Tree, density: 100, terrainZone: TerrainZone.Grass }],
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
4. **Extend runner** — generate map + create physics colliders in constructor, call `capturePreStartState()` after.
|
|
416
|
+
|
|
417
|
+
5. **Render** — use `MapTerrainRenderer` and `MapObjectRenderer` from `@lagless/2d-map-renderer`.
|
|
418
|
+
|
|
419
|
+
6. **Canopy transparency** — use `extractCanopyZones()` + `isInsideCanopyZone()` per frame.
|
|
420
|
+
|
|
421
|
+
> Full details: [docs/11-2d-map-generation.md](11-2d-map-generation.md)
|
|
422
|
+
<% } -%>
|
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
# 2D Map Generation
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`@lagless/2d-map-generator` produces deterministic 2D maps from a seed using a feature pipeline. `@lagless/2d-map-renderer` renders those maps using Pixi.js. Together they provide procedural terrain, rivers, lakes, object placement, and rendering for top-down 2D games.
|
|
6
|
+
|
|
7
|
+
**Key properties:**
|
|
8
|
+
- **Deterministic** — same seed + same config = identical map on every client (uses `MathOps` trig, `ISeededRandom`)
|
|
9
|
+
- **Feature-based** — compose terrain, water, objects via independent features with automatic dependency resolution
|
|
10
|
+
- **Physics-integrated** — `createMapColliders()` converts placed objects into Rapier 2D rigid bodies
|
|
11
|
+
- **Render-ready** — `MapTerrainRenderer` and `MapObjectRenderer` output Pixi.js containers
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @lagless/2d-map-generator @lagless/2d-map-renderer
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Both packages are peer dependencies — they are NOT included by default. Add them when your game needs procedural map generation.
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
MapGenerator
|
|
25
|
+
├── addFeature(feature, config) // register features
|
|
26
|
+
└── generate(random, collision) // run all features in dependency order
|
|
27
|
+
│
|
|
28
|
+
├── BiomeFeature → BiomeOutput (color palette)
|
|
29
|
+
├── ShoreFeature → ShoreOutput (island shore polygon)
|
|
30
|
+
├── GrassFeature → GrassOutput (grass area polygon)
|
|
31
|
+
├── RiverFeature → RiverOutput (river polygons)
|
|
32
|
+
├── LakeFeature → LakeOutput (lake polygons)
|
|
33
|
+
├── BridgeFeature → BridgeOutput (bridge placements)
|
|
34
|
+
├── ObjectPlacementFeature → ObjectPlacementOutput (placed objects)
|
|
35
|
+
├── GroundPatchFeature → GroundPatchOutput (ground patches)
|
|
36
|
+
└── PlacesFeature → PlacesOutput (named positions)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Features declare dependencies via `requires`. The generator resolves them with topological sort — no manual ordering needed. You only add the features your game needs.
|
|
40
|
+
|
|
41
|
+
## Integration Flow
|
|
42
|
+
|
|
43
|
+
The full integration spans three layers:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
1. Simulation (runner constructor)
|
|
47
|
+
└── MapGenerator.generate() → IGeneratedMap
|
|
48
|
+
└── createMapColliders() → Rapier 2D physics bodies
|
|
49
|
+
└── capturePreStartState() → snapshot includes map colliders
|
|
50
|
+
|
|
51
|
+
2. DI Bridge
|
|
52
|
+
└── MapData class registered via extraRegistrations
|
|
53
|
+
└── Systems access map data via DI constructor injection
|
|
54
|
+
|
|
55
|
+
3. Client (React/Pixi.js)
|
|
56
|
+
└── MapTerrainRenderer.buildTerrain() → terrain container
|
|
57
|
+
└── MapObjectRenderer.build() → ground + canopy ParticleContainers
|
|
58
|
+
└── extractCanopyZones() + isInsideCanopyZone() → per-frame transparency
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Setting Up the Generator
|
|
62
|
+
|
|
63
|
+
### Step 1: Define Object Types and Registry
|
|
64
|
+
|
|
65
|
+
Create a file in your simulation package (e.g., `map-config/objects.ts`):
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import type { MapObjectDef, MapObjectRegistry } from '@lagless/2d-map-generator';
|
|
69
|
+
import { RenderLayer, ShapeType, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
|
|
70
|
+
|
|
71
|
+
export enum ObjectType { Tree = 0, Building = 1 }
|
|
72
|
+
|
|
73
|
+
const TREE: MapObjectDef = {
|
|
74
|
+
typeId: ObjectType.Tree,
|
|
75
|
+
colliders: [
|
|
76
|
+
{ shape: { type: ShapeType.Circle, radius: 30 } },
|
|
77
|
+
// Sensor for canopy transparency zone (view-only, skipped by createMapColliders)
|
|
78
|
+
{ shape: { type: ShapeType.Circle, radius: 128 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
|
|
79
|
+
],
|
|
80
|
+
visuals: [
|
|
81
|
+
{ texture: 'tree-trunk', layer: RenderLayer.Ground },
|
|
82
|
+
{ texture: 'tree-foliage', layer: RenderLayer.Canopy },
|
|
83
|
+
],
|
|
84
|
+
scaleRange: [0.1, 0.2],
|
|
85
|
+
// Include sensor radius in placement bounds (prevents canopy overlap)
|
|
86
|
+
includeSensorsInBounds: true,
|
|
87
|
+
// Optional: minimap display
|
|
88
|
+
mapDisplay: {
|
|
89
|
+
shapes: [
|
|
90
|
+
{ collider: { type: ShapeType.Circle, radius: 30 }, color: 0x2d5a1e, scale: 1 },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const BUILDING: MapObjectDef = {
|
|
96
|
+
typeId: ObjectType.Building,
|
|
97
|
+
colliders: [
|
|
98
|
+
{ shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 } },
|
|
99
|
+
{ shape: { type: ShapeType.Cuboid, halfWidth: 30, halfHeight: 20 }, isSensor: true, tag: CANOPY_SENSOR_TAG },
|
|
100
|
+
],
|
|
101
|
+
visuals: [
|
|
102
|
+
{ texture: 'building-floor', layer: RenderLayer.Ground },
|
|
103
|
+
{ texture: 'building-roof', layer: RenderLayer.Canopy },
|
|
104
|
+
],
|
|
105
|
+
scaleRange: [1, 1],
|
|
106
|
+
groundPatches: [
|
|
107
|
+
{
|
|
108
|
+
offset: { x: 0, y: 0 },
|
|
109
|
+
halfExtents: { x: 32, y: 22 },
|
|
110
|
+
color: 0x8b4513,
|
|
111
|
+
roughness: 0.5,
|
|
112
|
+
offsetDist: 2,
|
|
113
|
+
order: 0,
|
|
114
|
+
useAsMapShape: false,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const OBJECT_REGISTRY: MapObjectRegistry = new Map<number, MapObjectDef>([
|
|
120
|
+
[ObjectType.Tree, TREE],
|
|
121
|
+
[ObjectType.Building, BUILDING],
|
|
122
|
+
]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Step 2: Create Map Generator Factory
|
|
126
|
+
|
|
127
|
+
Create `map-config/create-map-generator.ts`:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
import {
|
|
131
|
+
MapGenerator, BiomeFeature, ShoreFeature, GrassFeature,
|
|
132
|
+
RiverFeature, LakeFeature, ObjectPlacementFeature,
|
|
133
|
+
PlacementKind, TerrainZone, STANDARD_BIOME,
|
|
134
|
+
} from '@lagless/2d-map-generator';
|
|
135
|
+
import { OBJECT_REGISTRY, ObjectType } from './objects.js';
|
|
136
|
+
|
|
137
|
+
export function createMapGenerator(): MapGenerator {
|
|
138
|
+
const generator = new MapGenerator({
|
|
139
|
+
baseWidth: 720,
|
|
140
|
+
baseHeight: 720,
|
|
141
|
+
scale: 1.0,
|
|
142
|
+
extension: 80,
|
|
143
|
+
gridSize: 16,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
generator
|
|
147
|
+
.addFeature(new BiomeFeature(), STANDARD_BIOME)
|
|
148
|
+
.addFeature(new ShoreFeature(), { inset: 48, divisions: 12, variation: 4 })
|
|
149
|
+
.addFeature(new GrassFeature(), { inset: 18, variation: 3 })
|
|
150
|
+
.addFeature(new RiverFeature(), {
|
|
151
|
+
weights: [
|
|
152
|
+
{ weight: 0.25, widths: [8, 4] },
|
|
153
|
+
{ weight: 0.75, widths: [4] },
|
|
154
|
+
],
|
|
155
|
+
subdivisionPasses: 5,
|
|
156
|
+
masks: [],
|
|
157
|
+
})
|
|
158
|
+
.addFeature(new LakeFeature(), {
|
|
159
|
+
lakes: [{ odds: 1.0, innerRad: 30, outerRad: 200, spawnBound: { pos: { x: 0.5, y: 0.5 }, rad: 300 } }],
|
|
160
|
+
})
|
|
161
|
+
.addFeature(new ObjectPlacementFeature(), {
|
|
162
|
+
registry: OBJECT_REGISTRY,
|
|
163
|
+
stages: [
|
|
164
|
+
{ kind: PlacementKind.Density, typeId: ObjectType.Tree, density: 100, terrainZone: TerrainZone.Grass },
|
|
165
|
+
{ kind: PlacementKind.Fixed, typeId: ObjectType.Building, count: 3, terrainZone: TerrainZone.Grass },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return generator;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Step 3: Create MapData DI Token
|
|
174
|
+
|
|
175
|
+
Create `map-data.ts` in your simulation:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import type { IGeneratedMap, MapObjectRegistry } from '@lagless/2d-map-generator';
|
|
179
|
+
|
|
180
|
+
export class MapData {
|
|
181
|
+
map!: IGeneratedMap;
|
|
182
|
+
registry!: MapObjectRegistry;
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Step 4: Extend Runner with Map Generation
|
|
187
|
+
|
|
188
|
+
Create a runner subclass that generates the map and creates physics colliders:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { AbstractInputProvider, ECSConfig, PRNG } from '@lagless/core';
|
|
192
|
+
import { PhysicsConfig2d, PhysicsWorldManager2d, type RapierModule2d, RapierRigidBody2d } from '@lagless/physics2d';
|
|
193
|
+
import {
|
|
194
|
+
SpatialGridCollisionProvider, ObjectPlacementFeature,
|
|
195
|
+
createMapColliders, CANOPY_SENSOR_TAG,
|
|
196
|
+
} from '@lagless/2d-map-generator';
|
|
197
|
+
import type { ObjectPlacementOutput, MapPhysicsProvider } from '@lagless/2d-map-generator';
|
|
198
|
+
import { createMapGenerator } from './map-config/create-map-generator.js';
|
|
199
|
+
import { OBJECT_REGISTRY } from './map-config/objects.js';
|
|
200
|
+
import { MyGameRunner } from './schema/code-gen/MyGame.runner.js';
|
|
201
|
+
import { MyGameSystems } from './systems/index.js';
|
|
202
|
+
import { MyGameSignals } from './signals/index.js';
|
|
203
|
+
import { MapData } from './map-data.js';
|
|
204
|
+
|
|
205
|
+
function createPhysicsAdapter(wm: PhysicsWorldManager2d, rapier: RapierModule2d): MapPhysicsProvider {
|
|
206
|
+
return {
|
|
207
|
+
createFixedBody(x, y, rotation) {
|
|
208
|
+
const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
|
|
209
|
+
return wm.createBodyFromDesc(desc);
|
|
210
|
+
},
|
|
211
|
+
createCircleCollider(body, radius, ox, oy, isSensor, _tag, collisionGroup) {
|
|
212
|
+
let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
|
|
213
|
+
if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
|
|
214
|
+
wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
|
|
215
|
+
},
|
|
216
|
+
createCuboidCollider(body, hw, hh, ox, oy, isSensor, _tag, collisionGroup) {
|
|
217
|
+
let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
|
|
218
|
+
if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
|
|
219
|
+
wm.createColliderFromDesc(desc, body as RapierRigidBody2d);
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export class MyGameRunnerWithMap extends MyGameRunner {
|
|
225
|
+
constructor(
|
|
226
|
+
config: ECSConfig,
|
|
227
|
+
inputProvider: AbstractInputProvider,
|
|
228
|
+
rapier: RapierModule2d,
|
|
229
|
+
physicsConfig?: PhysicsConfig2d,
|
|
230
|
+
) {
|
|
231
|
+
const mapData = new MapData();
|
|
232
|
+
|
|
233
|
+
super(
|
|
234
|
+
config, inputProvider,
|
|
235
|
+
MyGameSystems, MyGameSignals,
|
|
236
|
+
rapier, physicsConfig, undefined,
|
|
237
|
+
[[MapData, mapData]], // Register MapData for DI
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Generate map using ECS PRNG (available after super)
|
|
241
|
+
const prng = this.DIContainer.resolve(PRNG);
|
|
242
|
+
const generator = createMapGenerator();
|
|
243
|
+
const collision = new SpatialGridCollisionProvider(1024, 1024, 64);
|
|
244
|
+
const map = generator.generate(prng, collision);
|
|
245
|
+
mapData.map = map;
|
|
246
|
+
mapData.registry = OBJECT_REGISTRY;
|
|
247
|
+
|
|
248
|
+
// Create physics colliders for placed objects
|
|
249
|
+
const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
250
|
+
if (placement) {
|
|
251
|
+
const physics = createPhysicsAdapter(this.PhysicsWorldManager, rapier);
|
|
252
|
+
createMapColliders(physics, placement.objects, mapData.registry, {
|
|
253
|
+
skipTags: [CANOPY_SENSOR_TAG],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// CRITICAL: re-capture initial state AFTER creating static bodies
|
|
258
|
+
// Without this, rollback to tick 0 restores a world without map colliders
|
|
259
|
+
this.Simulation.capturePreStartState();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Key points:**
|
|
265
|
+
- Map is generated in the runner constructor, BEFORE `start()` is called
|
|
266
|
+
- Uses ECS `PRNG` for determinism — same seed (from `serverHello.seed` in multiplayer) = same map
|
|
267
|
+
- `capturePreStartState()` MUST be called after creating static bodies — otherwise rollback loses them
|
|
268
|
+
- `MapData` is registered via `extraRegistrations` so systems can access map data through DI
|
|
269
|
+
- `skipTags: [CANOPY_SENSOR_TAG]` prevents creating physics bodies for view-only sensor colliders
|
|
270
|
+
|
|
271
|
+
## MapGenerator Configuration
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
const generator = new MapGenerator({
|
|
275
|
+
baseWidth: 720, // map width before scaling (pixels)
|
|
276
|
+
baseHeight: 720, // map height before scaling (pixels)
|
|
277
|
+
scale: 1.0, // multiplier applied to base dimensions
|
|
278
|
+
extension: 80, // extra border around the map (water area)
|
|
279
|
+
gridSize: 16, // terrain grid cell size for rendering
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The actual map dimensions are `(baseWidth + 2 * extension) * scale` by `(baseHeight + 2 * extension) * scale`.
|
|
284
|
+
|
|
285
|
+
## Object Definitions
|
|
286
|
+
|
|
287
|
+
### MapObjectDef
|
|
288
|
+
|
|
289
|
+
| Field | Type | Description |
|
|
290
|
+
|-------|------|-------------|
|
|
291
|
+
| `typeId` | `number` | Unique identifier for this object type |
|
|
292
|
+
| `colliders` | `MapColliderDef[]` | Physics collision shapes |
|
|
293
|
+
| `visuals` | `MapVisualDef[]` | Texture references with render layer |
|
|
294
|
+
| `scaleRange` | `[min, max]` | Random scale range applied during placement |
|
|
295
|
+
| `orientations` | `number[]?` | Allowed rotation angles (default: `[0]`) |
|
|
296
|
+
| `groundPatches` | `GroundPatchDef[]?` | Ground patches drawn under the object |
|
|
297
|
+
| `mapDisplay` | `MapDisplayDef?` | Minimap display shapes |
|
|
298
|
+
| `children` | `ChildObjectDef[]?` | Child objects spawned relative to parent |
|
|
299
|
+
| `includeSensorsInBounds` | `boolean?` | Include sensor colliders in placement AABB (default: false) |
|
|
300
|
+
|
|
301
|
+
### MapColliderDef
|
|
302
|
+
|
|
303
|
+
| Field | Type | Description |
|
|
304
|
+
|-------|------|-------------|
|
|
305
|
+
| `shape` | `MapCollisionShape` | `{ type: ShapeType.Circle, radius }` or `{ type: ShapeType.Cuboid, halfWidth, halfHeight }` |
|
|
306
|
+
| `offsetX` | `number?` | Offset from object center |
|
|
307
|
+
| `offsetY` | `number?` | Offset from object center |
|
|
308
|
+
| `isSensor` | `boolean?` | Sensor collider (no physics response) |
|
|
309
|
+
| `tag` | `number?` | Tag for filtering (e.g., `CANOPY_SENSOR_TAG`) |
|
|
310
|
+
| `collisionGroup` | `number?` | Rapier collision group bitmask |
|
|
311
|
+
|
|
312
|
+
### MapVisualDef
|
|
313
|
+
|
|
314
|
+
| Field | Type | Description |
|
|
315
|
+
|-------|------|-------------|
|
|
316
|
+
| `texture` | `string` | Texture key (resolved by `getTexture` callback in renderer) |
|
|
317
|
+
| `layer` | `RenderLayer` | `RenderLayer.Ground` (under entities) or `RenderLayer.Canopy` (over entities) |
|
|
318
|
+
| `offsetX/Y` | `number?` | Visual offset from object center |
|
|
319
|
+
| `anchorX/Y` | `number?` | Sprite anchor (default: 0.5, 0.5) |
|
|
320
|
+
|
|
321
|
+
## Placement Stages
|
|
322
|
+
|
|
323
|
+
Stages define how and where objects are placed. All stages run during `ObjectPlacementFeature.generate()`.
|
|
324
|
+
|
|
325
|
+
| Kind | Description | Key Fields |
|
|
326
|
+
|------|-------------|------------|
|
|
327
|
+
| `PlacementKind.Location` | Place at a specific position | `typeId`, `pos: {x, y}`, `rad`, `optional` |
|
|
328
|
+
| `PlacementKind.Fixed` | Place exact count randomly | `typeId`, `count`, `important?`, `terrainZone?` |
|
|
329
|
+
| `PlacementKind.Random` | Choose N types from a list | `spawns: number[]`, `choose`, `terrainZone?` |
|
|
330
|
+
| `PlacementKind.Density` | Count proportional to map area | `typeId`, `density`, `terrainZone?` |
|
|
331
|
+
|
|
332
|
+
### Examples
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
stages: [
|
|
336
|
+
// Place 1 tree per 100 sq. units of grass area
|
|
337
|
+
{ kind: PlacementKind.Density, typeId: 0, density: 100, terrainZone: TerrainZone.Grass },
|
|
338
|
+
|
|
339
|
+
// Place exactly 3 buildings on grass
|
|
340
|
+
{ kind: PlacementKind.Fixed, typeId: 1, count: 3, terrainZone: TerrainZone.Grass },
|
|
341
|
+
|
|
342
|
+
// Place a spawn point at (100, 100) within 20px radius; skip if placement fails
|
|
343
|
+
{ kind: PlacementKind.Location, typeId: 2, pos: { x: 100, y: 100 }, rad: 20, optional: true },
|
|
344
|
+
|
|
345
|
+
// Randomly pick 5 objects from types [0, 1, 2]
|
|
346
|
+
{ kind: PlacementKind.Random, spawns: [0, 1, 2], choose: 5 },
|
|
347
|
+
]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Terrain Zones
|
|
351
|
+
|
|
352
|
+
Restrict placement to specific terrain types:
|
|
353
|
+
|
|
354
|
+
| Zone | Value | Description |
|
|
355
|
+
|------|-------|-------------|
|
|
356
|
+
| `TerrainZone.Grass` | 0 | Main land area |
|
|
357
|
+
| `TerrainZone.Beach` | 1 | Shore/beach area |
|
|
358
|
+
| `TerrainZone.RiverShore` | 2 | Riverbank |
|
|
359
|
+
| `TerrainZone.River` | 3 | Inside river |
|
|
360
|
+
| `TerrainZone.Lake` | 4 | Inside lake |
|
|
361
|
+
| `TerrainZone.Bridge` | 5 | On a bridge |
|
|
362
|
+
| `TerrainZone.WaterEdge` | 6 | Water edge |
|
|
363
|
+
|
|
364
|
+
## Collision Providers
|
|
365
|
+
|
|
366
|
+
Collision providers prevent object overlap during placement. Two options:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
import { SpatialGridCollisionProvider, RapierCollisionProvider } from '@lagless/2d-map-generator';
|
|
370
|
+
|
|
371
|
+
// Fast grid-based provider (recommended for most cases)
|
|
372
|
+
const collision = new SpatialGridCollisionProvider(mapWidth, mapHeight, cellSize);
|
|
373
|
+
|
|
374
|
+
// Rapier-based provider (more accurate, slower — use when shapes need exact overlap testing)
|
|
375
|
+
const collision = new RapierCollisionProvider(rapier);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
## Terrain Query
|
|
379
|
+
|
|
380
|
+
Classify world positions into terrain zones at runtime:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import { TerrainQuery, TerrainZone } from '@lagless/2d-map-generator';
|
|
384
|
+
import type { ShoreOutput, GrassOutput, RiverOutput, LakeOutput } from '@lagless/2d-map-generator';
|
|
385
|
+
import { ShoreFeature, GrassFeature, RiverFeature, LakeFeature } from '@lagless/2d-map-generator';
|
|
386
|
+
|
|
387
|
+
const terrain = new TerrainQuery({
|
|
388
|
+
shore: map.get<ShoreOutput>(ShoreFeature),
|
|
389
|
+
grass: map.get<GrassOutput>(GrassFeature),
|
|
390
|
+
river: map.get<RiverOutput>(RiverFeature),
|
|
391
|
+
lake: map.get<LakeOutput>(LakeFeature),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const zone = terrain.classify(playerX, playerY); // TerrainZone.Grass, .Beach, etc.
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Useful for terrain-dependent game logic (speed modifiers, footstep sounds, spawn restrictions).
|
|
398
|
+
|
|
399
|
+
## Physics Integration
|
|
400
|
+
|
|
401
|
+
### MapPhysicsProvider Adapter
|
|
402
|
+
|
|
403
|
+
`createMapColliders()` uses a `MapPhysicsProvider` adapter to create physics bodies. This decouples the generator from Rapier's API:
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
import { createMapColliders, CANOPY_SENSOR_TAG } from '@lagless/2d-map-generator';
|
|
407
|
+
import type { MapPhysicsProvider } from '@lagless/2d-map-generator';
|
|
408
|
+
|
|
409
|
+
const physics: MapPhysicsProvider = {
|
|
410
|
+
createFixedBody(x, y, rotation) {
|
|
411
|
+
const desc = rapier.RigidBodyDesc.fixed().setTranslation(x, y).setRotation(rotation);
|
|
412
|
+
return worldManager.createBodyFromDesc(desc);
|
|
413
|
+
},
|
|
414
|
+
createCircleCollider(body, radius, ox, oy, isSensor, tag, collisionGroup) {
|
|
415
|
+
let desc = rapier.ColliderDesc.ball(radius).setTranslation(ox, oy).setSensor(isSensor);
|
|
416
|
+
if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
|
|
417
|
+
worldManager.createColliderFromDesc(desc, body);
|
|
418
|
+
},
|
|
419
|
+
createCuboidCollider(body, hw, hh, ox, oy, isSensor, tag, collisionGroup) {
|
|
420
|
+
let desc = rapier.ColliderDesc.cuboid(hw, hh).setTranslation(ox, oy).setSensor(isSensor);
|
|
421
|
+
if (collisionGroup != null) desc = desc.setCollisionGroups(collisionGroup);
|
|
422
|
+
worldManager.createColliderFromDesc(desc, body);
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
// Create colliders, skipping canopy sensors (view-only)
|
|
427
|
+
createMapColliders(physics, placement.objects, registry, {
|
|
428
|
+
skipTags: [CANOPY_SENSOR_TAG],
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### skipTags Option
|
|
433
|
+
|
|
434
|
+
| Option | Type | Description |
|
|
435
|
+
|--------|------|-------------|
|
|
436
|
+
| `skipTags` | `readonly number[]` | Skip colliders whose `tag` is in this list |
|
|
437
|
+
|
|
438
|
+
Use `skipTags: [CANOPY_SENSOR_TAG]` to prevent creating physics bodies for canopy transparency sensors — they are view-only and don't need physics responses.
|
|
439
|
+
|
|
440
|
+
### capturePreStartState (CRITICAL)
|
|
441
|
+
|
|
442
|
+
Static map bodies must be created BEFORE calling `capturePreStartState()`:
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
// 1. Generate map + create colliders (in runner constructor)
|
|
446
|
+
// 2. Re-capture initial snapshot:
|
|
447
|
+
this.Simulation.capturePreStartState();
|
|
448
|
+
// 3. Start simulation:
|
|
449
|
+
runner.start();
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Without this, rollback to tick 0/1 restores a physics world without map colliders.
|
|
453
|
+
|
|
454
|
+
## Rendering
|
|
455
|
+
|
|
456
|
+
### MapTerrainRenderer
|
|
457
|
+
|
|
458
|
+
Renders terrain layers (background, beach, grass, rivers, lakes, grid, ground patches):
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import { MapTerrainRenderer } from '@lagless/2d-map-renderer';
|
|
462
|
+
|
|
463
|
+
const terrain = new MapTerrainRenderer();
|
|
464
|
+
const terrainContainer = terrain.buildTerrain(map);
|
|
465
|
+
viewport.addChildAt(terrainContainer, 0); // add at bottom of display list
|
|
466
|
+
|
|
467
|
+
// Cleanup:
|
|
468
|
+
terrain.destroy();
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### MapObjectRenderer
|
|
472
|
+
|
|
473
|
+
Renders placed objects as two `ParticleContainer` layers — ground (under entities) and canopy (over entities):
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
import { MapObjectRenderer } from '@lagless/2d-map-renderer';
|
|
477
|
+
import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
|
|
478
|
+
import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
|
|
479
|
+
import { Assets, Texture } from 'pixi.js';
|
|
480
|
+
|
|
481
|
+
const objectRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
|
|
482
|
+
|
|
483
|
+
const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
484
|
+
if (placement) {
|
|
485
|
+
objectRenderer.build(
|
|
486
|
+
placement.objects,
|
|
487
|
+
registry,
|
|
488
|
+
(textureKey) => Assets.get<Texture>(textureKey) ?? Texture.EMPTY,
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
viewport.addChild(objectRenderer.ground); // under entities
|
|
492
|
+
// ... add entity views here ...
|
|
493
|
+
viewport.addChild(objectRenderer.canopy); // over entities
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Cleanup:
|
|
497
|
+
objectRenderer.destroy();
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**Display order:** terrain → `objectRenderer.ground` → entity sprites → `objectRenderer.canopy`
|
|
501
|
+
|
|
502
|
+
### Canopy Transparency
|
|
503
|
+
|
|
504
|
+
Canopy transparency is a **view-only** concern — it must NOT live in ECS or affect determinism. When a player is under a tree/building canopy, the canopy becomes transparent so the player remains visible.
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
import { extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
|
|
508
|
+
import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
|
|
509
|
+
|
|
510
|
+
// Pre-compute once (e.g., in useMemo):
|
|
511
|
+
const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
512
|
+
const canopyZones = placement ? extractCanopyZones(placement.objects, registry) : [];
|
|
513
|
+
|
|
514
|
+
// Per frame (e.g., in useTick):
|
|
515
|
+
const px = playerX, py = playerY;
|
|
516
|
+
for (const zone of canopyZones) {
|
|
517
|
+
const inside = isInsideCanopyZone(zone, px, py);
|
|
518
|
+
objectRenderer.setCanopyAlpha(zone.objectIndex, inside ? 0.3 : 1.0);
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
**How it works:**
|
|
523
|
+
1. `extractCanopyZones()` finds all sensor colliders tagged with `CANOPY_SENSOR_TAG` (default tag)
|
|
524
|
+
2. Returns `CanopyZone[]` with pre-computed `radiusSq` (for circles) or `halfWidth/halfHeight` (for cuboids)
|
|
525
|
+
3. `isInsideCanopyZone()` performs the appropriate distance check based on zone type
|
|
526
|
+
4. `objectRenderer.setCanopyAlpha()` sets the alpha of the canopy particle at that index
|
|
527
|
+
|
|
528
|
+
**Performance:** O(N) per frame with N objects — just a distance comparison per object, negligible cost.
|
|
529
|
+
|
|
530
|
+
### MinimapRenderer
|
|
531
|
+
|
|
532
|
+
Renders a simplified minimap:
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
import { MinimapRenderer } from '@lagless/2d-map-renderer';
|
|
536
|
+
import { ObjectPlacementFeature } from '@lagless/2d-map-generator';
|
|
537
|
+
import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
|
|
538
|
+
|
|
539
|
+
const minimap = new MinimapRenderer();
|
|
540
|
+
const minimapContainer = minimap.buildMinimap(map, 200); // 200px size
|
|
541
|
+
|
|
542
|
+
// Add object dots to minimap (uses mapDisplay shapes from object definitions)
|
|
543
|
+
const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
544
|
+
if (placement) {
|
|
545
|
+
minimap.addObjectShapes(placement.objects, registry);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
stage.addChild(minimapContainer);
|
|
549
|
+
|
|
550
|
+
// Cleanup:
|
|
551
|
+
minimap.destroy();
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Objects only show on the minimap if their `MapObjectDef` has a `mapDisplay` property with shapes.
|
|
555
|
+
|
|
556
|
+
## Accessing Feature Outputs
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
import type { BiomeOutput, ShoreOutput, GrassOutput, RiverOutput, ObjectPlacementOutput } from '@lagless/2d-map-generator';
|
|
560
|
+
import { BiomeFeature, ShoreFeature, GrassFeature, RiverFeature, ObjectPlacementFeature } from '@lagless/2d-map-generator';
|
|
561
|
+
|
|
562
|
+
const map = generator.generate(random, collision);
|
|
563
|
+
|
|
564
|
+
// Type-safe access via feature class:
|
|
565
|
+
const biome = map.get<BiomeOutput>(BiomeFeature); // color palette
|
|
566
|
+
const shore = map.get<ShoreOutput>(ShoreFeature); // island polygon
|
|
567
|
+
const grass = map.get<GrassOutput>(GrassFeature); // grass polygon + area
|
|
568
|
+
const river = map.get<RiverOutput>(RiverFeature); // river polygons
|
|
569
|
+
const placement = map.get<ObjectPlacementOutput>(ObjectPlacementFeature); // placed objects
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
## Biome Colors
|
|
573
|
+
|
|
574
|
+
Use `STANDARD_BIOME` for default colors, or define custom:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import { STANDARD_BIOME } from '@lagless/2d-map-generator';
|
|
578
|
+
|
|
579
|
+
// Standard biome (green grass, blue water, sandy beach):
|
|
580
|
+
generator.addFeature(new BiomeFeature(), STANDARD_BIOME);
|
|
581
|
+
|
|
582
|
+
// Custom biome:
|
|
583
|
+
generator.addFeature(new BiomeFeature(), {
|
|
584
|
+
background: 0x80af49,
|
|
585
|
+
water: 0x3d85c6,
|
|
586
|
+
waterRipple: 0x3478b2,
|
|
587
|
+
beach: 0xcdb35b,
|
|
588
|
+
riverbank: 0x905e24,
|
|
589
|
+
grass: 0x80af49,
|
|
590
|
+
underground: 0x1b0d00,
|
|
591
|
+
});
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
## Ground Patches
|
|
595
|
+
|
|
596
|
+
Objects can define `groundPatches` — colored rectangles drawn under the object (e.g., building foundations, dirt patches):
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
groundPatches: [
|
|
600
|
+
{
|
|
601
|
+
offset: { x: 0, y: 0 }, // offset from object center
|
|
602
|
+
halfExtents: { x: 12, y: 10 }, // half-size of the rectangle
|
|
603
|
+
color: 0x8b4513, // fill color
|
|
604
|
+
roughness: 0.5, // edge roughness (0 = smooth)
|
|
605
|
+
offsetDist: 2, // random edge offset distance
|
|
606
|
+
order: 0, // 0 = under grid, 1 = over grid
|
|
607
|
+
useAsMapShape: false, // whether to use as map boundary shape
|
|
608
|
+
},
|
|
609
|
+
]
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
Add `GroundPatchFeature` to your generator to enable ground patches:
|
|
613
|
+
|
|
614
|
+
```typescript
|
|
615
|
+
import { GroundPatchFeature } from '@lagless/2d-map-generator';
|
|
616
|
+
|
|
617
|
+
generator.addFeature(new GroundPatchFeature(), { registry: OBJECT_REGISTRY });
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
## Utilities
|
|
621
|
+
|
|
622
|
+
### sortPlacedObjects
|
|
623
|
+
|
|
624
|
+
Sorts placed objects by position (Y then X). Used internally by both `MapObjectRenderer.build()` and `extractCanopyZones()` to guarantee consistent object indices.
|
|
625
|
+
|
|
626
|
+
```typescript
|
|
627
|
+
import { sortPlacedObjects } from '@lagless/2d-map-generator';
|
|
628
|
+
|
|
629
|
+
const sorted = sortPlacedObjects(placement.objects);
|
|
630
|
+
// sorted[i] index matches MapObjectRenderer particle index and CanopyZone.objectIndex
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### CANOPY_SENSOR_TAG
|
|
634
|
+
|
|
635
|
+
Constant (`= 1`) used as a tag on sensor colliders to mark canopy transparency zones. Used by:
|
|
636
|
+
- `extractCanopyZones()` — default tag parameter
|
|
637
|
+
- `createMapColliders()` with `skipTags` — prevents creating physics bodies for canopy sensors
|
|
638
|
+
|
|
639
|
+
## Determinism Notes
|
|
640
|
+
|
|
641
|
+
All map generation is deterministic:
|
|
642
|
+
- Uses `ISeededRandom` interface — the ECS `PRNG` satisfies this structurally
|
|
643
|
+
- Trigonometry uses `MathOps` (WASM-backed, cross-platform identical)
|
|
644
|
+
- Same seed + same config = identical map on every client
|
|
645
|
+
- Map generation happens ONCE before simulation starts, not during ticks
|
|
646
|
+
- In multiplayer, the seed comes from `serverHello.seed` — guaranteed identical for all clients
|
|
647
|
+
|
|
648
|
+
## Enums Reference
|
|
649
|
+
|
|
650
|
+
| Enum | Values |
|
|
651
|
+
|------|--------|
|
|
652
|
+
| `ShapeType` | `Circle = 0`, `Cuboid = 1` |
|
|
653
|
+
| `PlacementKind` | `Location = 0`, `Fixed = 1`, `Random = 2`, `Density = 3` |
|
|
654
|
+
| `RenderLayer` | `Ground = 0`, `Canopy = 1` |
|
|
655
|
+
| `TerrainZone` | `Grass = 0`, `Beach = 1`, `RiverShore = 2`, `River = 3`, `Lake = 4`, `Bridge = 5`, `WaterEdge = 6` |
|
|
656
|
+
|
|
657
|
+
## Full Client Example
|
|
658
|
+
|
|
659
|
+
```typescript
|
|
660
|
+
// In your game view component:
|
|
661
|
+
import { FC, useEffect, useMemo, useRef } from 'react';
|
|
662
|
+
import { useTick } from '@pixi/react';
|
|
663
|
+
import { Assets, Texture } from 'pixi.js';
|
|
664
|
+
import { MapTerrainRenderer, MapObjectRenderer } from '@lagless/2d-map-renderer';
|
|
665
|
+
import { ObjectPlacementFeature, extractCanopyZones, isInsideCanopyZone } from '@lagless/2d-map-generator';
|
|
666
|
+
import type { ObjectPlacementOutput } from '@lagless/2d-map-generator';
|
|
667
|
+
|
|
668
|
+
export const MapView: FC<{ runner: MyGameRunner; viewport: Viewport }> = ({ runner, viewport }) => {
|
|
669
|
+
const mapData = useMemo(() => runner.DIContainer.resolve(MapData), [runner]);
|
|
670
|
+
const objectRendererRef = useRef<MapObjectRenderer | null>(null);
|
|
671
|
+
|
|
672
|
+
// Build terrain + objects once
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
const terrain = new MapTerrainRenderer();
|
|
675
|
+
viewport.addChildAt(terrain.buildTerrain(mapData.map), 0);
|
|
676
|
+
|
|
677
|
+
const objRenderer = new MapObjectRenderer({ dynamicCanopyAlpha: true });
|
|
678
|
+
objectRendererRef.current = objRenderer;
|
|
679
|
+
const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
680
|
+
if (placement) {
|
|
681
|
+
objRenderer.build(placement.objects, mapData.registry, (key) => Assets.get<Texture>(key) ?? Texture.EMPTY);
|
|
682
|
+
viewport.addChild(objRenderer.ground);
|
|
683
|
+
viewport.addChild(objRenderer.canopy);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return () => { terrain.destroy(); objRenderer.destroy(); };
|
|
687
|
+
}, [viewport, mapData]);
|
|
688
|
+
|
|
689
|
+
// Pre-compute canopy zones
|
|
690
|
+
const canopyZones = useMemo(() => {
|
|
691
|
+
const placement = mapData.map.get<ObjectPlacementOutput>(ObjectPlacementFeature);
|
|
692
|
+
return placement ? extractCanopyZones(placement.objects, mapData.registry) : [];
|
|
693
|
+
}, [mapData]);
|
|
694
|
+
|
|
695
|
+
// Per-frame canopy transparency
|
|
696
|
+
useTick(() => {
|
|
697
|
+
const objRenderer = objectRendererRef.current;
|
|
698
|
+
if (!objRenderer) return;
|
|
699
|
+
const px = playerX, py = playerY; // get from transform
|
|
700
|
+
for (const zone of canopyZones) {
|
|
701
|
+
objRenderer.setCanopyAlpha(zone.objectIndex, isInsideCanopyZone(zone, px, py) ? 0.3 : 1.0);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
return null;
|
|
706
|
+
};
|
|
707
|
+
```
|
package/templates/pixi-react/__packageName__-simulation/src/lib/systems/hash-verification.system.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { ECSConfig, ECSSystem, InputProvider, PlayerResources, AbstractHashVerificationSystem, DivergenceSignal } from '@lagless/core';
|
|
2
|
-
import { PlayerResource, ReportHash } from '../schema/code-gen/index.js';
|
|
3
|
-
|
|
4
|
-
@ECSSystem()
|
|
5
|
-
export class HashVerificationSystem extends AbstractHashVerificationSystem {
|
|
6
|
-
protected readonly _reportHashRpc = ReportHash;
|
|
7
|
-
protected readonly _playerResourceClass = PlayerResource;
|
|
8
|
-
|
|
9
|
-
constructor(
|
|
10
|
-
ecsConfig: ECSConfig,
|
|
11
|
-
inputProvider: InputProvider,
|
|
12
|
-
playerResources: PlayerResources,
|
|
13
|
-
divergenceSignal: DivergenceSignal,
|
|
14
|
-
) {
|
|
15
|
-
super(ecsConfig, inputProvider, playerResources, divergenceSignal);
|
|
16
|
-
}
|
|
17
|
-
}
|