From 8b549162c422dee4aa731d444a73a9a448cd979c Mon Sep 17 00:00:00 2001 From: Paula Date: Sat, 26 Sep 2026 11:03:07 -0300 Subject: [PATCH] Day night cycle --- MODS.md | 10 ++++- client/client.ts | 1 + client/gui/debug_overlay.ts | 3 ++ client/level/client_level.ts | 3 ++ client/mods.ts | 3 ++ client/network.ts | 2 + client/packet_listener.ts | 3 ++ client/rendering/game_renderer.ts | 24 ++++++++++- common/mod_api/client.ts | 6 ++- common/mod_api/server.ts | 3 ++ common/protocol.ts | 8 +++- common/time.ts | 67 +++++++++++++++++++++++++++++++ server/game/game_server.ts | 44 ++++++++++++++++++++ server/game/mod_runtime.ts | 13 +++++- server/game/world.ts | 3 ++ tests/time_test.ts | 57 ++++++++++++++++++++++++++ 16 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 common/time.ts create mode 100644 tests/time_test.ts diff --git a/MODS.md b/MODS.md index 223c5d5..c4e8b9d 100644 --- a/MODS.md +++ b/MODS.md @@ -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 ` and `/time add `. + 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 diff --git a/client/client.ts b/client/client.ts index feb8e6d..ce91f02 100644 --- a/client/client.ts +++ b/client/client.ts @@ -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); } diff --git a/client/gui/debug_overlay.ts b/client/gui/debug_overlay.ts index a253b2a..16acd6c 100644 --- a/client/gui/debug_overlay.ts +++ b/client/gui/debug_overlay.ts @@ -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}`)) { diff --git a/client/level/client_level.ts b/client/level/client_level.ts index bd27cc5..38b34b2 100644 --- a/client/level/client_level.ts +++ b/client/level/client_level.ts @@ -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>(); @@ -129,6 +131,7 @@ export class ClientLevel { } tick() { + this.time += 1; for (const entity of this.entities.values()) { entity.save_previous_position(); entity.tick(); diff --git a/client/mods.ts b/client/mods.ts index 48cd012..7781077 100644 --- a/client/mods.ts +++ b/client/mods.ts @@ -100,6 +100,9 @@ function client_context(listing: ModListing): ClientContext { if (nid === AIR) return AIR_ID; return EverythingRegistry.get_by_id("blocks", nid)?.id; }, + get time() { + return need_client().level.time; + }, }, log: (...args) => console.log(`[${mod}]`, ...args), }; diff --git a/client/network.ts b/client/network.ts index 443fe7c..2277a17 100644 --- a/client/network.ts +++ b/client/network.ts @@ -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; } diff --git a/client/packet_listener.ts b/client/packet_listener.ts index d3fa568..d53527c 100644 --- a/client/packet_listener.ts +++ b/client/packet_listener.ts @@ -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; diff --git a/client/rendering/game_renderer.ts b/client/rendering/game_renderer.ts index 65e0f65..25e39c5 100644 --- a/client/rendering/game_renderer.ts +++ b/client/rendering/game_renderer.ts @@ -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); diff --git a/common/mod_api/client.ts b/common/mod_api/client.ts index 62d812d..141aefd 100644 --- a/common/mod_api/client.ts +++ b/common/mod_api/client.ts @@ -11,7 +11,11 @@ export interface ClientContext { net: ClientNet; player: { readonly name: string; readonly position: Readonly }; // 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; } diff --git a/common/mod_api/server.ts b/common/mod_api/server.ts index 392cb7a..b8aa8af 100644 --- a/common/mod_api/server.ts +++ b/common/mod_api/server.ts @@ -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 { diff --git a/common/protocol.ts b/common/protocol.ts index f45e35f..648abbd 100644 --- a/common/protocol.ts +++ b/common/protocol.ts @@ -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 } | { type: "screen_properties"; properties: Record } - | { 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; diff --git a/common/time.ts b/common/time.ts new file mode 100644 index 0000000..4d8c73e --- /dev/null +++ b/common/time.ts @@ -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 = { + 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); +} diff --git a/server/game/game_server.ts b/server/game/game_server.ts index fde5735..2bb410e 100644 --- a/server/game/game_server.ts +++ b/server/game/game_server.ts @@ -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 changes it, /time add + #time_command(player: ServerPlayer, [action, value]: string[]) { + const usage = "Usage: /time [set | add ]"; + 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; diff --git a/server/game/mod_runtime.ts b/server/game/mod_runtime.ts index 37fab4a..b5954ac 100644 --- a/server/game/mod_runtime.ts +++ b/server/game/mod_runtime.ts @@ -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)), diff --git a/server/game/world.ts b/server/game/world.ts index 18b20d5..15bcdbf 100644 --- a/server/game/world.ts +++ b/server/game/world.ts @@ -41,6 +41,9 @@ export class ServerWorld { #changes = new Map>(); tiles = new Map(); + // ticks since 06:00 of day 1, see common/time.ts + time = 0; + // set when anything that gets saved changes dirty = false; diff --git a/tests/time_test.ts b/tests/time_test.ts new file mode 100644 index 0000000..02998e9 --- /dev/null +++ b/tests/time_test.ts @@ -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); +});