Skip to content

Commit 8a20849

Browse files
committed
feat: refactor environment handling to support location-based environments; implement environment editor and remove location switcher
1 parent 71ff474 commit 8a20849

16 files changed

Lines changed: 385 additions & 248 deletions

File tree

src/entity/vessel/ship.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,10 @@ export function createVesselEntity(model: ModelEntity, vesselId?: string, store?
138138
}
139139

140140
if (sailForce && t > 0 && store) {
141-
const env = store.get('environment');
142-
const wind = env.wind;
141+
const locations = store.get('locations');
142+
const activeLoc = store.get('activeLocation');
143+
const env = locations[activeLoc]?.environment;
144+
const wind = env?.wind;
143145
if (wind) {
144146
const wDirX = Math.sin(wind.direction);
145147
const wDirZ = -Math.cos(wind.direction);

src/kernel.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ import type { ScenePlugin } from './plugins/types';
1010
describe('StateStore integration', () => {
1111
it('stores and retrieves values by dotted path', () => {
1212
const store = new StateStore(createDefaultState());
13-
expect(store.get('environment.sky.gradientTop')).toBe('#5588bb');
13+
expect(store.get('locations.north-sea.environment.sky.gradientTop')).toBe('#5588bb');
1414
});
1515

1616
it('notifies subscribers on change', () => {
1717
const store = new StateStore(createDefaultState());
1818
const fn = vi.fn();
19-
store.subscribe('environment.sky.gradientTop', fn);
20-
store.set('environment.sky.gradientTop', '#ff0000');
21-
expect(fn).toHaveBeenCalledWith('#ff0000', 'environment.sky.gradientTop');
19+
store.subscribe('locations.north-sea.environment.sky.gradientTop', fn);
20+
store.set('locations.north-sea.environment.sky.gradientTop', '#ff0000');
21+
expect(fn).toHaveBeenCalledWith('#ff0000', 'locations.north-sea.environment.sky.gradientTop');
2222
});
2323
});
2424

src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { LOCATION_PRESETS, CCIV_WORLD } from './state/worlds';
66
import { inspectorPlugin } from './plugins/inspector';
77
import { gizmosPlugin } from './plugins/gizmos';
88
import { snapshotPlugin } from './plugins/snapshot';
9-
import { locationSwitcherPlugin } from './plugins/location-switcher';
109
import { simulationPlugin } from './plugins/simulation';
1110
import { modeTogglePlugin } from './plugins/mode-toggle';
1211
import { sceneGraphPlugin } from './plugins/scene-graph';
1312
import { performanceHudPlugin } from './plugins/performance-hud';
1413
import { shipHudPlugin } from './plugins/ship-hud';
1514
import { environmentControllerPlugin, initEnvController } from './plugins/environment-controller';
1615
import { physicsDebugPlugin } from './plugins/physics-debug';
16+
import { environmentEditorPlugin } from './plugins/environment-editor';
1717
import { mountReactShell } from './ui/main';
1818
import { bridgeStore } from './ui/bridge';
1919
async function main() {
@@ -22,13 +22,13 @@ async function main() {
2222
kernel.registerPlugin(inspectorPlugin);
2323
kernel.registerPlugin(gizmosPlugin);
2424
kernel.registerPlugin(snapshotPlugin);
25-
kernel.registerPlugin(locationSwitcherPlugin);
2625
kernel.registerPlugin(simulationPlugin);
2726
kernel.registerPlugin(modeTogglePlugin);
2827
kernel.registerPlugin(sceneGraphPlugin);
2928
kernel.registerPlugin(performanceHudPlugin);
3029
kernel.registerPlugin(shipHudPlugin);
3130
kernel.registerPlugin(physicsDebugPlugin);
31+
kernel.registerPlugin(environmentEditorPlugin);
3232
kernel.registerPlugin(environmentControllerPlugin);
3333
initEnvController(kernel.scene);
3434
const { scene, store } = kernel;

src/plugins/environment-controller/index.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type { EnvironmentState, WeatherType } from '../../state/types';
88
import type { FogSpec } from '../../scene/types';
99

1010
let _scene: import('../../scene/types').IScene | null = null;
11-
let _currentWeather: WeatherType = 'clear';
1211

1312
interface TransitionState {
1413
fromFog: FogSpec;
@@ -46,7 +45,7 @@ function rebuildEnvironment(env: EnvironmentState): void {
4645

4746
const current = entityManager.getEntities();
4847
for (const e of current) {
49-
if (e.id === 'environment' || e.id === 'rain') entityManager.detach(e);
48+
if (e.id === 'environment' || e.id === 'rain' || e.id === 'mist') entityManager.detach(e);
5049
}
5150

5251
const effective = computeEffectiveEnvironment(env);
@@ -59,30 +58,58 @@ function rebuildEnvironment(env: EnvironmentState): void {
5958
entityManager.attach(createEnvironmentEntity(env), s);
6059
}
6160

61+
function getActiveEnvironment(ctx: PluginContext): EnvironmentState {
62+
const loc = ctx.state.get('activeLocation') as string;
63+
const envs = ctx.state.get('locations') as Record<string, { environment: EnvironmentState }>;
64+
return envs[loc]?.environment;
65+
}
66+
67+
/** Apply the current environment state immediately (used by environment editor). */
68+
export function applyEnvironment(ctx: PluginContext): void {
69+
const env = getActiveEnvironment(ctx);
70+
if (env) rebuildEnvironment(env);
71+
}
72+
6273
export const environmentControllerPlugin: ScenePlugin = {
6374
id: 'environment-controller',
6475
label: 'Environment Controller',
6576
modes: new Set(['edit', 'play']),
6677
priority: 100,
6778

6879
init(ctx: PluginContext) {
69-
_currentWeather = (ctx.state.get('environment.weather') as WeatherType) ?? 'clear';
70-
71-
const initEnv = ctx.state.get('environment') as EnvironmentState;
72-
const initEffective = computeEffectiveEnvironment(initEnv);
73-
if (_scene) {
74-
_scene.fog = initEffective.fog;
75-
if (initEffective.sky) {
76-
_scene.background = initEffective.sky.gradientTop;
80+
const initEnv = getActiveEnvironment(ctx);
81+
if (initEnv) {
82+
const initEffective = computeEffectiveEnvironment(initEnv);
83+
if (_scene) {
84+
_scene.fog = initEffective.fog;
85+
if (initEffective.sky) {
86+
_scene.background = initEffective.sky.gradientTop;
87+
}
7788
}
7889
}
7990

80-
ctx.state.watch(s => s.environment.weather, (w) => {
81-
if (w === _currentWeather) return;
82-
_currentWeather = w ?? 'clear';
91+
let _lastLocation = ctx.state.get('activeLocation') as string;
92+
let _lastWeather: WeatherType = initEnv?.weather ?? 'clear';
93+
94+
ctx.state.watch(s => ({
95+
loc: s.activeLocation,
96+
weather: s.locations[s.activeLocation]?.environment.weather ?? 'clear',
97+
}), ({ loc, weather }) => {
98+
if (loc !== _lastLocation) {
99+
_lastLocation = loc;
100+
_lastWeather = weather;
101+
_transition = null;
102+
const env = getActiveEnvironment(ctx);
103+
if (env) rebuildEnvironment(env);
104+
return;
105+
}
83106

84-
const baseEnv = ctx.state.get('environment') as EnvironmentState;
85-
const toEnv = { ...baseEnv, weather: _currentWeather };
107+
if (weather === _lastWeather) return;
108+
_lastWeather = weather;
109+
110+
const env = getActiveEnvironment(ctx);
111+
if (!env) return;
112+
const toEnv = { ...env, weather };
86113
const toEffective = computeEffectiveEnvironment(toEnv);
87114
const fromFog = _scene?.fog ?? toEffective.fog;
88115
const fromBg = _scene?.background ?? toEffective.sky?.gradientTop ?? '#406888';
@@ -98,13 +125,6 @@ export const environmentControllerPlugin: ScenePlugin = {
98125
toEnv,
99126
};
100127
});
101-
102-
ctx.state.watch(s => s.activeLocation, () => {
103-
_transition = null;
104-
const env = ctx.state.get('environment') as EnvironmentState;
105-
const merged = { ...env, weather: _currentWeather };
106-
rebuildEnvironment(merged);
107-
});
108128
},
109129

110130
render(dt: number) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { ScenePlugin, PluginContext } from '../types';
2+
import { registerTool, destroyTool } from '../sidebar';
3+
import { LocationEnvironmentPanel } from '../../ui/components/location-environment-panel';
4+
import { initLocationCtx } from '../../ui/stores/location-store';
5+
6+
export const environmentEditorPlugin: ScenePlugin = {
7+
id: 'environment-editor',
8+
label: 'Location & Environment',
9+
modes: new Set(['edit', 'play']),
10+
priority: 5,
11+
12+
init(ctx: PluginContext) {
13+
initLocationCtx(ctx);
14+
registerTool({ id: 'environment-editor', label: 'Location & Environment', icon: '🌍', component: LocationEnvironmentPanel });
15+
},
16+
17+
destroy() {
18+
destroyTool('environment-editor');
19+
},
20+
};

src/plugins/location-switcher/index.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/plugins/ship-hud/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ export const shipHudPlugin: ScenePlugin = (() => {
4848
const store = useShipHudStore.getState();
4949
if (!store.visible) return;
5050

51-
const env = (window as any).__store?.get('environment') as any;
52-
const windSpeed = (env?.ocean?.windSpeed ?? 12).toFixed(1);
53-
const swellHeight = (env?.ocean?.swellHeight ?? 2.4).toFixed(1);
51+
const s = (window as any).__store;
52+
const loc = s?.get('activeLocation');
53+
const locs = s?.get('locations') as Record<string, { environment: { wind: { speed: number }; waves: { amplitude: number }[] } }> | undefined;
54+
const env = locs?.[loc]?.environment;
55+
const windSpeed = (env?.wind?.speed ?? 12).toFixed(1);
56+
const swellHeight = (env?.waves?.[0]?.amplitude ?? 2.4).toFixed(1);
5457

5558
timeAccum += dt;
5659
const totalMinutes = Math.floor(timeAccum * 1.5) % 1440;

src/state/defaults.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ describe('createDefaultState', () => {
55
it('returns a valid AppState', () => {
66
const state = createDefaultState();
77
expect(state.activeLocation).toBe('north-sea');
8-
expect(state.environment.sky.gradientTop).toBeTruthy();
9-
expect(state.environment.waves.length).toBe(8);
8+
expect(state.locations['north-sea'].environment.sky.gradientTop).toBeTruthy();
9+
expect(state.locations['north-sea'].environment.waves.length).toBe(8);
1010
expect(state.instances.ship.visible).toBe(true);
1111
expect(Object.keys(state.locations)).toContain('north-sea');
1212
});

src/state/defaults.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
import type { AppState } from './types';
22
import { LOCATION_PRESETS } from './worlds';
33

4+
function addWeatherToLocation(env: EnvironmentState): EnvironmentState {
5+
return { ...structuredClone(env), weather: 'clear' };
6+
}
7+
48
export function createDefaultState(): AppState {
5-
const preset = LOCATION_PRESETS['north-sea'];
69
return {
710
activeLocation: 'north-sea',
811
dirtyLocations: [],
912
time: { speed: 1, paused: false, elapsed: 0 },
10-
environment: { ...structuredClone(preset.environment), weather: 'clear' },
11-
instances: structuredClone(preset.instances),
12-
locations: structuredClone(LOCATION_PRESETS),
13+
instances: structuredClone(LOCATION_PRESETS['north-sea'].instances),
14+
locations: Object.fromEntries(
15+
Object.entries(LOCATION_PRESETS).map(([id, preset]) => [
16+
id,
17+
{ environment: addWeatherToLocation(preset.environment), instances: structuredClone(preset.instances) },
18+
]),
19+
),
1320
};
1421
}

src/state/location-tracker.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('LocationTracker', () => {
1515

1616
it('marks location dirty when environment changes', () => {
1717
expect(store.get('dirtyLocations')).toEqual([]);
18-
store.set('environment.fog.color', '#ff0000');
18+
store.set('locations.north-sea.environment.fog.color', '#ff0000');
1919
expect(store.get('dirtyLocations')).toEqual(['north-sea']);
2020
});
2121

@@ -27,24 +27,24 @@ describe('LocationTracker', () => {
2727

2828
it('does not add duplicate dirty locations', () => {
2929
expect(store.get('dirtyLocations')).toEqual([]);
30-
store.set('environment.fog.color', '#ff0000');
30+
store.set('locations.north-sea.environment.fog.color', '#ff0000');
3131
store.set('instances.ship.transform.position', [10, 20, 30]);
3232
expect(store.get('dirtyLocations')).toEqual(['north-sea']);
3333
});
3434

3535
it('cleans up subscription on stop()', () => {
3636
const spy = vi.spyOn(store, 'subscribe');
3737
tracker.stop();
38-
store.set('environment.fog.color', '#ff0000');
38+
store.set('locations.north-sea.environment.fog.color', '#ff0000');
3939
expect(store.get('dirtyLocations')).toEqual([]);
4040
});
4141

4242
it('handles activeLocation changes correctly', () => {
4343
// Setup a second location
4444
const initialState = createDefaultState();
45+
const northEnv = initialState.locations['north-sea'].environment;
4546
initialState.locations['south-sea'] = {
46-
id: 'south-sea',
47-
environment: initialState.environment,
47+
environment: structuredClone(northEnv),
4848
instances: {},
4949
};
5050
store = new StateStore(initialState);
@@ -54,11 +54,11 @@ describe('LocationTracker', () => {
5454
store.set('activeLocation', 'south-sea');
5555
expect(store.get('dirtyLocations')).toEqual([]);
5656

57-
store.set('environment.fog.color', '#00ff00'); // Change in south-sea
57+
store.set('locations.south-sea.environment.fog.color', '#00ff00'); // Change in south-sea
5858
expect(store.get('dirtyLocations')).toEqual(['south-sea']);
5959

6060
store.set('activeLocation', 'north-sea');
61-
store.set('environment.sky.gradientBottom', '#0000ff'); // Change in north-sea
61+
store.set('locations.north-sea.environment.sky.gradientBottom', '#0000ff'); // Change in north-sea
6262
expect(store.get('dirtyLocations')).toEqual(['south-sea', 'north-sea']);
6363
});
6464
});

0 commit comments

Comments
 (0)