Add project time settings command and related files

This commit is contained in:
2026-04-12 04:29:39 +02:00
parent a7f1c7f21b
commit c00d3ce6c8
3 changed files with 431 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { createOpaqueId } from "../core/ids";
import {
cloneProjectTimeSettings,
type ProjectTimeSettings
} from "../document/project-time-settings";
import type { EditorCommand } from "./command";
interface SetProjectTimeSettingsCommandOptions {
label: string;
time: ProjectTimeSettings;
}
export function createSetProjectTimeSettingsCommand(
options: SetProjectTimeSettingsCommandOptions
): EditorCommand {
const nextTime = cloneProjectTimeSettings(options.time);
let previousTime: ProjectTimeSettings | null = null;
return {
id: createOpaqueId("command"),
label: options.label,
execute(context) {
const currentProjectDocument = context.getProjectDocument();
if (previousTime === null) {
previousTime = cloneProjectTimeSettings(currentProjectDocument.time);
}
context.setProjectDocument({
...currentProjectDocument,
time: cloneProjectTimeSettings(nextTime)
});
},
undo(context) {
if (previousTime === null) {
return;
}
const currentProjectDocument = context.getProjectDocument();
context.setProjectDocument({
...currentProjectDocument,
time: cloneProjectTimeSettings(previousTime)
});
}
};
}

View File

@@ -0,0 +1,48 @@
export const HOURS_PER_DAY = 24 as const;
export const DEFAULT_PROJECT_DAY_LENGTH_MINUTES = 24 as const;
export const DEFAULT_PROJECT_START_TIME_OF_DAY_HOURS = 9 as const;
export interface ProjectTimeSettings {
startTimeOfDayHours: number;
dayLengthMinutes: number;
}
export function normalizeTimeOfDayHours(hours: number): number {
const wrappedHours = hours % HOURS_PER_DAY;
return wrappedHours < 0 ? wrappedHours + HOURS_PER_DAY : wrappedHours;
}
export function formatTimeOfDayHours(hours: number): string {
const normalizedHours = normalizeTimeOfDayHours(hours);
const totalMinutes = Math.round(normalizedHours * 60) % (HOURS_PER_DAY * 60);
const wholeHours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${String(wholeHours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
export function createDefaultProjectTimeSettings(): ProjectTimeSettings {
return {
startTimeOfDayHours: DEFAULT_PROJECT_START_TIME_OF_DAY_HOURS,
dayLengthMinutes: DEFAULT_PROJECT_DAY_LENGTH_MINUTES
};
}
export function cloneProjectTimeSettings(
settings: ProjectTimeSettings
): ProjectTimeSettings {
return {
startTimeOfDayHours: settings.startTimeOfDayHours,
dayLengthMinutes: settings.dayLengthMinutes
};
}
export function areProjectTimeSettingsEqual(
left: ProjectTimeSettings,
right: ProjectTimeSettings
): boolean {
return (
left.startTimeOfDayHours === right.startTimeOfDayHours &&
left.dayLengthMinutes === right.dayLengthMinutes
);
}

View File

@@ -0,0 +1,336 @@
import type { Vec3 } from "../core/vector";
import {
formatTimeOfDayHours,
HOURS_PER_DAY,
normalizeTimeOfDayHours,
type ProjectTimeSettings
} from "../document/project-time-settings";
import {
cloneWorldBackgroundSettings,
type WorldAmbientLightSettings,
type WorldBackgroundSettings,
type WorldSettings,
type WorldSunLightSettings
} from "../document/world-settings";
const NIGHT_AMBIENT_COLOR = "#162033";
const NIGHT_SUN_COLOR = "#6f7fb5";
const NIGHT_SOLID_BACKGROUND_COLOR = "#09111f";
const NIGHT_GRADIENT_TOP_COLOR = "#0b1730";
const NIGHT_GRADIENT_BOTTOM_COLOR = "#182134";
const DEFAULT_NOON_DIRECTION: Vec3 = {
x: 0.45,
y: 0.88,
z: 0.15
};
const UP_AXIS: Vec3 = {
x: 0,
y: 1,
z: 0
};
export interface RuntimeClockState {
timeOfDayHours: number;
dayCount: number;
dayLengthMinutes: number;
}
export interface RuntimeDayNightWorldState {
ambientLight: WorldAmbientLightSettings;
sunLight: WorldSunLightSettings;
background: WorldBackgroundSettings;
daylightFactor: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function smoothstep(edge0: number, edge1: number, value: number): number {
if (edge0 === edge1) {
return value < edge0 ? 0 : 1;
}
const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function lerp(left: number, right: number, amount: number): number {
return left + (right - left) * amount;
}
function normalizeVec3(vector: Vec3): Vec3 {
const length = Math.hypot(vector.x, vector.y, vector.z);
if (length <= 1e-6) {
return {
...DEFAULT_NOON_DIRECTION
};
}
return {
x: vector.x / length,
y: vector.y / length,
z: vector.z / length
};
}
function cross(left: Vec3, right: Vec3): Vec3 {
return {
x: left.y * right.z - left.z * right.y,
y: left.z * right.x - left.x * right.z,
z: left.x * right.y - left.y * right.x
};
}
function dot(left: Vec3, right: Vec3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function scaleVec3(vector: Vec3, scale: number): Vec3 {
return {
x: vector.x * scale,
y: vector.y * scale,
z: vector.z * scale
};
}
function addVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x + right.x,
y: left.y + right.y,
z: left.z + right.z
};
}
function rotateAroundAxis(vector: Vec3, axis: Vec3, radians: number): Vec3 {
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
return normalizeVec3(
addVec3(
addVec3(
scaleVec3(vector, cosine),
scaleVec3(cross(axis, vector), sine)
),
scaleVec3(axis, dot(axis, vector) * (1 - cosine))
)
);
}
function parseHexColor(colorHex: string): { r: number; g: number; b: number } {
return {
r: Number.parseInt(colorHex.slice(1, 3), 16),
g: Number.parseInt(colorHex.slice(3, 5), 16),
b: Number.parseInt(colorHex.slice(5, 7), 16)
};
}
function formatHexColor(color: { r: number; g: number; b: number }): string {
const toHex = (value: number) =>
Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0");
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
}
function blendHexColors(leftHex: string, rightHex: string, amount: number): string {
const left = parseHexColor(leftHex);
const right = parseHexColor(rightHex);
return formatHexColor({
r: lerp(left.r, right.r, amount),
g: lerp(left.g, right.g, amount),
b: lerp(left.b, right.b, amount)
});
}
function resolveNoonSunDirection(direction: Vec3): Vec3 {
const normalizedDirection = normalizeVec3(direction);
if (normalizedDirection.y >= 0.2) {
return normalizedDirection;
}
return normalizeVec3({
x: normalizedDirection.x,
y: Math.abs(normalizedDirection.y) + 0.35,
z: normalizedDirection.z
});
}
function resolveTimeDrivenSunDirection(
noonDirection: Vec3,
timeOfDayHours: number
): Vec3 {
const orbitAxisCandidate = cross(noonDirection, UP_AXIS);
const orbitAxis =
Math.hypot(
orbitAxisCandidate.x,
orbitAxisCandidate.y,
orbitAxisCandidate.z
) <= 1e-6
? {
x: 1,
y: 0,
z: 0
}
: normalizeVec3(orbitAxisCandidate);
const orbitRadians =
((normalizeTimeOfDayHours(timeOfDayHours) - 12) / HOURS_PER_DAY) *
Math.PI * 2;
return rotateAroundAxis(noonDirection, orbitAxis, orbitRadians);
}
function resolveTimeDrivenBackground(
background: WorldBackgroundSettings,
daylightFactor: number
): WorldBackgroundSettings {
if (background.mode === "solid") {
return {
mode: "solid",
colorHex: blendHexColors(
NIGHT_SOLID_BACKGROUND_COLOR,
background.colorHex,
daylightFactor
)
};
}
if (background.mode === "verticalGradient") {
return {
mode: "verticalGradient",
topColorHex: blendHexColors(
NIGHT_GRADIENT_TOP_COLOR,
background.topColorHex,
daylightFactor
),
bottomColorHex: blendHexColors(
NIGHT_GRADIENT_BOTTOM_COLOR,
background.bottomColorHex,
daylightFactor
)
};
}
return cloneWorldBackgroundSettings(background);
}
export function createRuntimeClockState(
settings: ProjectTimeSettings
): RuntimeClockState {
return {
timeOfDayHours: normalizeTimeOfDayHours(settings.startTimeOfDayHours),
dayCount: 0,
dayLengthMinutes: settings.dayLengthMinutes
};
}
export function cloneRuntimeClockState(
state: RuntimeClockState
): RuntimeClockState {
return {
timeOfDayHours: state.timeOfDayHours,
dayCount: state.dayCount,
dayLengthMinutes: state.dayLengthMinutes
};
}
export function areRuntimeClockStatesEqual(
left: RuntimeClockState,
right: RuntimeClockState
): boolean {
return (
left.timeOfDayHours === right.timeOfDayHours &&
left.dayCount === right.dayCount &&
left.dayLengthMinutes === right.dayLengthMinutes
);
}
export function reconfigureRuntimeClockState(
state: RuntimeClockState,
settings: ProjectTimeSettings
): RuntimeClockState {
return {
...cloneRuntimeClockState(state),
dayLengthMinutes: settings.dayLengthMinutes
};
}
export function advanceRuntimeClockState(
state: RuntimeClockState,
dtSeconds: number
): RuntimeClockState {
if (dtSeconds <= 0) {
return cloneRuntimeClockState(state);
}
const safeDayLengthMinutes = Math.max(state.dayLengthMinutes, 0.001);
const hoursAdvanced =
(dtSeconds / (safeDayLengthMinutes * 60)) * HOURS_PER_DAY;
const absoluteHours = state.timeOfDayHours + hoursAdvanced;
return {
timeOfDayHours: normalizeTimeOfDayHours(absoluteHours),
dayCount: state.dayCount + Math.floor(absoluteHours / HOURS_PER_DAY),
dayLengthMinutes: state.dayLengthMinutes
};
}
export function formatRuntimeClockTime(state: RuntimeClockState): string {
return formatTimeOfDayHours(state.timeOfDayHours);
}
export function resolveRuntimeDayNightWorldState(
world: WorldSettings,
clock: RuntimeClockState | null
): RuntimeDayNightWorldState {
if (clock === null) {
return {
ambientLight: {
colorHex: world.ambientLight.colorHex,
intensity: world.ambientLight.intensity
},
sunLight: {
colorHex: world.sunLight.colorHex,
intensity: world.sunLight.intensity,
direction: {
...world.sunLight.direction
}
},
background: cloneWorldBackgroundSettings(world.background),
daylightFactor: 1
};
}
const noonDirection = resolveNoonSunDirection(world.sunLight.direction);
const sunDirection = resolveTimeDrivenSunDirection(
noonDirection,
clock.timeOfDayHours
);
const daylightFactor = smoothstep(-0.2, 0.18, sunDirection.y);
const ambientFactor = lerp(0.18, 1, daylightFactor);
const sunFactor = lerp(0.02, 1, smoothstep(-0.05, 0.22, sunDirection.y));
return {
ambientLight: {
colorHex: blendHexColors(
NIGHT_AMBIENT_COLOR,
world.ambientLight.colorHex,
lerp(0.15, 1, daylightFactor)
),
intensity: world.ambientLight.intensity * ambientFactor
},
sunLight: {
colorHex: blendHexColors(
NIGHT_SUN_COLOR,
world.sunLight.colorHex,
lerp(0.1, 1, daylightFactor)
),
intensity: world.sunLight.intensity * sunFactor,
direction: sunDirection
},
background: resolveTimeDrivenBackground(world.background, daylightFactor),
daylightFactor
};
}