Day night cycle

This commit is contained in:
2026-09-26 11:03:07 -03:00
parent 8f32a59bd6
commit 8b549162c4
16 changed files with 244 additions and 6 deletions
+9 -1
View File
@@ -497,6 +497,8 @@ interface ServerWorld {
drop_item(x: number, y: number, z: number, item: ItemStack): void; // pops out of the block, like drops
is_loaded(x: number, z: number): boolean;
readonly seed: string;
readonly time: number; // world time, see below
set_time(time: number): void; // synced to every player
}
interface Player {
@@ -533,6 +535,12 @@ interface ModStorage {
}
```
**World time** counts ticks since 06:00 of day 1 and never wraps around. A day (06:00 to 18:00) is 36000 ticks, 30
minutes, and a night (18:00 to 06:00) is 12000 ticks, 10 minutes, so in game hours are shorter at night. Dusk is the
first minute of the night and dawn the last, so it's bright all day. `common/time.ts` has `clock`, `format_clock`,
`is_day` and `daylight` for turning it into something readable. Client scripts can read it as `ctx.world.time`, and
players can use `/time`, `/time set <ticks|day|noon|sunset|night|midnight>` and `/time add <ticks>`.
The server loads chunks around every player (its simulation distance) and generates them itself with the same generator
as the clients. Blocks in unloaded chunks can't be read or changed, and don't tick.
@@ -549,7 +557,7 @@ ctx.commands.register("heal", {
```
Commands run on the server when a player types `/name` in chat. Two mods using the same name, or a mod using one of the
engine's (`give` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
engine's (`give`, `time` and `tps`), is a load error. `/copper_tools:heal` always works as the unambiguous form.
## Client scripts
+1
View File
@@ -53,6 +53,7 @@ export class Client {
this.#on_disconnect = on_disconnect;
this.level = new ClientLevel(connection.seed);
this.level.time = connection.time;
for (const [x, y, z, id, state] of connection.initial_changes) {
this.level.record_change(x, y, z, id, state);
}
+3
View File
@@ -1,10 +1,13 @@
import type { Client } from "$/client/client.ts";
import { DebugUI } from "$/client/debug_ui.ts";
import { format_clock } from "$/common/time.ts";
// f3: every entity's fields, editable
export class DebugOverlay {
render(client: Client) {
DebugUI.begin("Entities", 10, 10, 300);
DebugUI.text(`${format_clock(client.level.time)} (tick ${client.level.time})`);
DebugUI.separator();
for (const entity of client.level.entities.values()) {
if (DebugUI.collapsing_header(`${entity.constructor.name} - ${entity.id}`)) {
+3
View File
@@ -87,6 +87,8 @@ export class ClientLevel {
second_timer = 0;
tick_timer = 0;
seed: string;
// world time in ticks, counted here between the server's updates. see common/time.ts
time = 0;
// blocks players changed from the generated terrain, per chunk, so they survive reloading chunks
changes = new Map<string, Map<string, BlockChange>>();
@@ -129,6 +131,7 @@ export class ClientLevel {
}
tick() {
this.time += 1;
for (const entity of this.entities.values()) {
entity.save_previous_position();
entity.tick();
+3
View File
@@ -100,6 +100,9 @@ function client_context(listing: ModListing): ClientContext {
if (nid === AIR) return AIR_ID;
return EverythingRegistry.get_by_id<BlockRegistry>("blocks", nid)?.id;
},
get time() {
return need_client().level.time;
},
},
log: (...args) => console.log(`[${mod}]`, ...args),
};
+2
View File
@@ -11,6 +11,7 @@ export class Connection {
initial_changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
time: number;
// who was already there when we joined, they become entities in the level
initial_players: PlayerInfo[];
initial_entities: EntityInfo[];
@@ -24,6 +25,7 @@ export class Connection {
this.initial_changes = join.changes;
this.spawn = join.spawn;
this.selected_slot = join.selected_slot;
this.time = join.time;
this.initial_players = join.players;
this.initial_entities = join.entities;
}
+3
View File
@@ -31,6 +31,9 @@ export class ClientPacketListener {
case "player_join":
level.add_entity(new RemotePlayer(level, message.player));
break;
case "time":
level.time = message.time;
break;
case "player_leave":
level.remove_entity(message.id);
break;
+23 -1
View File
@@ -1,19 +1,41 @@
import type { Client } from "$/client/client.ts";
import { begin_mode_3d, end_mode_3d } from "$/client/renderer/mod.ts";
import { begin_mode_3d, clear_background, end_mode_3d, update_lightmap } from "$/client/renderer/mod.ts";
import { daylight } from "$/common/time.ts";
import { Hud } from "$/client/gui/hud.ts";
import { DebugOverlay } from "$/client/gui/debug_overlay.ts";
import { LevelRenderer } from "./level_renderer.ts";
// the sky at noon and in the middle of the night
const DAY_SKY = [0.69, 0.8, 1];
const NIGHT_SKY = [0.02, 0.03, 0.08];
// minecraft's darkest sky: moonlight still lights things a little
const NIGHT_SKY_LIGHT = 0.2;
// draws a frame, like minecraft's GameRenderer: the level from the camera, then the hud and screens on top
export class GameRenderer {
level_renderer = new LevelRenderer();
hud = new Hud();
debug_overlay = new DebugOverlay();
// what the lightmap was last made for, so it's only rebuilt when the light changes
#lightmap_daylight = -1;
// sky color and sky light for the time of day
#setup_sky(client: Client, partial_tick: number) {
const light = daylight(client.level.time + partial_tick);
const [r, g, b] = DAY_SKY.map((day, i) => NIGHT_SKY[i] + (day - NIGHT_SKY[i]) * light);
clear_background(r, g, b, 1);
if (Math.abs(light - this.#lightmap_daylight) > 0.001) {
this.#lightmap_daylight = light;
update_lightmap(NIGHT_SKY_LIGHT + (1 - NIGHT_SKY_LIGHT) * light);
}
}
// partial_tick is how far this frame is between the last tick and the next, entities are drawn in between
render(client: Client, partial_tick: number) {
const camera = client.camera;
camera.setup(client.player, partial_tick);
this.#setup_sky(client, partial_tick);
begin_mode_3d(camera);
this.level_renderer.render_opaque(client.level, camera);
+5 -1
View File
@@ -11,7 +11,11 @@ export interface ClientContext {
net: ClientNet;
player: { readonly name: string; readonly position: Readonly<Position> };
// read only, what this client sees
world: { get_block(x: number, y: number, z: number): Id | undefined };
world: {
get_block(x: number, y: number, z: number): Id | undefined;
// ticks since 06:00 of day 1, see ServerWorld.time
readonly time: number;
};
log(...args: unknown[]): void;
}
+3
View File
@@ -128,6 +128,9 @@ export interface ServerWorld {
drop_item(x: number, y: number, z: number, item: ItemStack): void;
is_loaded(x: number, z: number): boolean;
readonly seed: string;
// ticks since 06:00 of day 1. a day is 36000 ticks (06:00 to 18:00), a night 12000
readonly time: number;
set_time(time: number): void;
}
export interface Player {
+6 -2
View File
@@ -7,7 +7,7 @@ export const AIR_ID = "bworld:air";
// bump when a client and server of different versions can't play together.
// the server rejects a different version before the client downloads anything
export const PROTOCOL_VERSION = 2;
export const PROTOCOL_VERSION = 3;
export interface PlayerInfo {
id: string;
@@ -101,6 +101,8 @@ export type ServerMessage =
changes: BlockChange[];
spawn: { x: number; y: number; z: number; yaw: number; pitch: number };
selected_slot: number;
// world time in ticks, see common/time.ts
time: number;
}
| { type: "player_join"; player: PlayerInfo }
| { type: "player_leave"; id: string }
@@ -120,7 +122,9 @@ export type ServerMessage =
| { type: "cursor"; item: ItemData | null }
| { type: "open_screen"; layout: ScreenLayout; properties: Record<string, number> }
| { type: "screen_properties"; properties: Record<string, number> }
| { type: "close_screen" };
| { type: "close_screen" }
// the world time, every second and when it's changed. clients count ticks in between
| { type: "time"; time: number };
export const MAX_NAME_LENGTH = 16;
export const MAX_CHAT_LENGTH = 256;
+67
View File
@@ -0,0 +1,67 @@
// the day and night cycle. world time counts ticks since 06:00 of day 1, it never wraps around
import { TICKS_PER_SECOND } from "./constants.ts";
// 30 minutes of day, 06:00 to 18:00
export const DAY_TICKS = 30 * 60 * TICKS_PER_SECOND;
// 10 minutes of night, 18:00 to 06:00
export const NIGHT_TICKS = 10 * 60 * TICKS_PER_SECOND;
export const CYCLE_TICKS = DAY_TICKS + NIGHT_TICKS;
// dusk right after sunset and dawn right before sunrise, so the whole day is bright
export const TWILIGHT_TICKS = 60 * TICKS_PER_SECOND;
// in game hours are longer during the day than at night, since both halves are 12 hours
const DAY_TICKS_PER_HOUR = DAY_TICKS / 12;
const NIGHT_TICKS_PER_HOUR = NIGHT_TICKS / 12;
// from midnight to 06:00, to count days from midnight
const TICKS_BEFORE_SIX = 6 * NIGHT_TICKS_PER_HOUR;
// named times of day, as ticks into the cycle
export const TIMES_OF_DAY: Record<string, number> = {
day: 0,
noon: 6 * DAY_TICKS_PER_HOUR,
sunset: DAY_TICKS,
night: DAY_TICKS + TWILIGHT_TICKS,
midnight: DAY_TICKS + 6 * NIGHT_TICKS_PER_HOUR,
};
// ticks into the current cycle, 0 is 06:00
export function time_of_day(time: number): number {
return ((time % CYCLE_TICKS) + CYCLE_TICKS) % CYCLE_TICKS;
}
export function is_day(time: number): boolean {
return time_of_day(time) < DAY_TICKS;
}
// the day number, starting at 1 and going up at midnight, and the time on a 24 hour clock
export function clock(time: number): { day: number; hour: number; minute: number } {
const t = time_of_day(time);
const hours = t < DAY_TICKS ? 6 + t / DAY_TICKS_PER_HOUR : 18 + (t - DAY_TICKS) / NIGHT_TICKS_PER_HOUR;
const total_minutes = Math.floor(hours * 60) % (24 * 60);
return {
day: Math.floor((time + TICKS_BEFORE_SIX) / CYCLE_TICKS) + 1,
hour: Math.floor(total_minutes / 60),
minute: total_minutes % 60,
};
}
// like "Day 3, 14:05"
export function format_clock(time: number): string {
const { day, hour, minute } = clock(time);
return `Day ${day}, ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
// how much daylight there is, 1 all day and 0 in the middle of the night, fading through dusk and dawn.
// time can be fractional, for drawing between ticks
export function daylight(time: number): number {
const t = time_of_day(time);
if (t < DAY_TICKS) return 1;
const into_night = t - DAY_TICKS;
const until_day = CYCLE_TICKS - t;
const fade = Math.min(1, into_night / TWILIGHT_TICKS, until_day / TWILIGHT_TICKS);
return 1 - smoothstep(fade);
}
function smoothstep(x: number) {
return x * x * (3 - 2 * x);
}
+44
View File
@@ -26,6 +26,7 @@ import {
} from "$/common/protocol.ts";
import type { AtlasListing, ModListing, RecipeBook } from "$/common/mod_loader.ts";
import type { WorldgenSetup } from "$/common/generation.ts";
import { CYCLE_TICKS, format_clock, time_of_day, TIMES_OF_DAY } from "$/common/time.ts";
import { ModRuntime, run_guarded } from "./mod_runtime.ts";
import type { GameLoop } from "./game_loop.ts";
import { consume_recipe_items, update_crafting_result } from "./crafting.ts";
@@ -62,6 +63,8 @@ export interface GameHost {
export interface SavedWorld {
version: 3;
seed: string;
// world time in ticks, 0 in saves from before the day and night cycle
time?: number;
changes: BlockChange[];
tiles: SavedTile[];
// every ctx.containers container, by id
@@ -124,6 +127,7 @@ export class GameServer {
this.world = new ServerWorld(saved?.seed ?? default_seed, mods.worldgen);
this.mods.storage = saved?.mod_storage ?? {};
this.world.load_changes(saved?.changes ?? []);
this.world.time = saved?.time ?? 0;
for (const [id, items] of Object.entries(saved?.containers ?? {})) {
const container = new Container(items.length);
container.load(items);
@@ -188,7 +192,11 @@ export class GameServer {
tick() {
this.#tick += 1;
this.world.time += 1;
const second = this.#tick % TICKS_PER_SECOND === 0;
if (second) {
this.#broadcast({ type: "time", time: this.world.time });
}
for (const tile of [...this.world.tiles.values()]) {
if (tile.mod_data === undefined) {
@@ -236,6 +244,7 @@ export class GameServer {
const saved: SavedWorld = {
version: 3,
seed: this.world.seed,
time: this.world.time,
changes: this.world.all_changes(),
tiles: [...this.world.tiles.values()].map((tile) => ({
id: tile.id,
@@ -342,6 +351,12 @@ export class GameServer {
this.#broadcast({ type: "player_move", id: player.id, x, y, z, yaw: player.yaw, pitch: player.pitch }, player);
}
set_time(time: number) {
this.world.time = time;
this.world.dirty = true;
this.#broadcast({ type: "time", time });
}
// changes a block's state bits without anything else happening, see ServerWorld.get_block_value
set_block_state(x: number, y: number, z: number, state: number) {
const id = this.world.get_block_id(x, y, z);
@@ -511,6 +526,7 @@ export class GameServer {
entities: [...this.#entities.values()].map((entity) => entity.info()),
spawn: { x: player.x, y: player.y, z: player.z, yaw: player.yaw, pitch: player.pitch },
selected_slot: player.selected_slot,
time: this.world.time,
});
this.#players.set(conn, player);
this.#sync(player);
@@ -820,6 +836,10 @@ export class GameServer {
this.give(player, item_id, amount);
return;
}
if (command === "time") {
this.#time_command(player, args);
return;
}
if (command === "tps") {
if (!this.loop) {
this.#send(player, { type: "chat", text: "The game loop isn't running" });
@@ -841,6 +861,30 @@ export class GameServer {
this.#send(player, { type: "chat", text: `Unknown command /${command}` });
}
// /time shows the time, /time set <ticks or day, noon, sunset, night, midnight> changes it, /time add <ticks>
#time_command(player: ServerPlayer, [action, value]: string[]) {
const usage = "Usage: /time [set <ticks|day|noon|sunset|night|midnight> | add <ticks>]";
if (action === undefined) {
this.#send(player, { type: "chat", text: `${format_clock(this.world.time)} (tick ${this.world.time})` });
return;
}
const ticks = Number(value);
if (action === "set" && value !== undefined && value in TIMES_OF_DAY) {
// the next time it's that time of day, so days keep counting up
const now = this.world.time;
const target = now - time_of_day(now) + TIMES_OF_DAY[value];
this.set_time(target >= now ? target : target + CYCLE_TICKS);
} else if (action === "set" && Number.isInteger(ticks) && ticks >= 0) {
this.set_time(ticks);
} else if (action === "add" && Number.isInteger(ticks) && this.world.time + ticks >= 0) {
this.set_time(this.world.time + ticks);
} else {
this.#send(player, { type: "chat", text: usage });
return;
}
this.#broadcast({ type: "chat", text: `${player.name} set the time to ${format_clock(this.world.time)}` });
}
#close_screen(player: ServerPlayer) {
const screen = player.screen;
player.screen = undefined;
+12 -1
View File
@@ -310,7 +310,9 @@ export class ModRuntime {
if (!/^[a-z0-9_]+$/.test(name)) {
throw new ModLoadError(mod, `command name "${name}" must be a-z, 0-9 and _`);
}
if (["give", "tps"].includes(name)) throw new ModLoadError(mod, `/${name} belongs to the engine`);
if (["give", "tps", "time"].includes(name)) {
throw new ModLoadError(mod, `/${name} belongs to the engine`);
}
const existing = this.commands.get(name);
if (existing) {
throw new ModLoadError(mod, `/${name} is also registered by ${existing.mod}`);
@@ -363,6 +365,15 @@ export class ModRuntime {
get seed() {
return game().world.seed;
},
get time() {
return game().world.time;
},
set_time: (time) => {
if (!Number.isInteger(time) || time < 0) {
throw new Error(`time must be a whole number of ticks, not ${time}`);
}
game().set_time(time);
},
},
players: {
all: () => game().players().map((p) => this.player(p)),
+3
View File
@@ -41,6 +41,9 @@ export class ServerWorld {
#changes = new Map<number, Map<string, BlockChange>>();
tiles = new Map<string, Tile>();
// ticks since 06:00 of day 1, see common/time.ts
time = 0;
// set when anything that gets saved changes
dirty = false;
+57
View File
@@ -0,0 +1,57 @@
import { assert, assertEquals } from "@std/assert";
import { clock, CYCLE_TICKS, DAY_TICKS, daylight, format_clock, is_day, NIGHT_TICKS } from "$/common/time.ts";
import { test_game } from "./helpers.ts";
Deno.test("a day is 30 minutes and a night 10, tick 0 is 06:00 of day 1", () => {
assertEquals(DAY_TICKS, 30 * 60 * 20);
assertEquals(NIGHT_TICKS, 10 * 60 * 20);
assertEquals(format_clock(0), "Day 1, 06:00");
assertEquals(format_clock(DAY_TICKS / 2), "Day 1, 12:00");
assertEquals(format_clock(DAY_TICKS), "Day 1, 18:00");
// midnight is halfway through the night, and the day number goes up there
assertEquals(format_clock(DAY_TICKS + NIGHT_TICKS / 2 - 1), "Day 1, 23:59");
assertEquals(format_clock(DAY_TICKS + NIGHT_TICKS / 2), "Day 2, 00:00");
assertEquals(format_clock(CYCLE_TICKS), "Day 2, 06:00");
assertEquals(clock(3 * CYCLE_TICKS + DAY_TICKS / 4), { day: 4, hour: 9, minute: 0 });
assert(is_day(DAY_TICKS - 1) && !is_day(DAY_TICKS) && is_day(CYCLE_TICKS));
});
Deno.test("it's bright all day, dark in the middle of the night, and fades in between", () => {
assertEquals(daylight(0), 1);
assertEquals(daylight(DAY_TICKS - 1), 1);
assertEquals(daylight(DAY_TICKS + NIGHT_TICKS / 2), 0);
const dusk = daylight(DAY_TICKS + 600);
const dawn = daylight(CYCLE_TICKS - 600);
assert(dusk > 0 && dusk < 1 && Math.abs(dusk - dawn) < 1e-9, `dusk ${dusk}, dawn ${dawn}`);
// smooth between ticks too
assert(daylight(DAY_TICKS + 600.5) < dusk);
});
Deno.test("the server keeps the time, tells players, saves it, and /time changes it", async () => {
const { game, join, take, send } = await test_game("mods");
assertEquals(join(1, "alice").time, 0);
for (let i = 0; i < 40; i++) game.tick();
assertEquals(take(1).filter((m) => m.type === "time").map((m) => m.time), [20, 40]);
send(1, { type: "chat", text: "/time" });
assertEquals(take(1).find((m) => m.type === "chat").text, "Day 1, 06:00 (tick 40)");
send(1, { type: "chat", text: "/time set midnight" });
let messages = take(1);
assertEquals(messages.find((m) => m.type === "time").time, DAY_TICKS + NIGHT_TICKS / 2);
assert(messages.some((m) => m.text === "alice set the time to Day 2, 00:00"));
// named times are the next one, so time never goes backwards
send(1, { type: "chat", text: "/time set day" });
assertEquals(game.world.time, CYCLE_TICKS);
send(1, { type: "chat", text: "/time add 100" });
assertEquals(game.world.time, CYCLE_TICKS + 100);
send(1, { type: "chat", text: "/time set nope" });
messages = take(1);
assert(messages.at(-1).text.startsWith("Usage: /time"));
const loaded = await test_game("mods", game.save());
assertEquals(loaded.game.world.time, CYCLE_TICKS + 100);
assertEquals(loaded.join(1, "alice").time, CYCLE_TICKS + 100);
});