Merge remote-tracking branch 'origin/lane/e'
This commit is contained in:
commit
903e1d5f95
59
THREADS.md
59
THREADS.md
@ -1849,3 +1849,62 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
flagged exactly this joint call when holding 0.45 — but it is now measured and it is SPRINT6 gate 1.
|
||||
Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run — verdicts must
|
||||
read the actual failure mode.
|
||||
|
||||
[E] 2026-07-17 — 🐛 **RETRACTION + FIX, and Lane A this one is a real bug I shipped and then documented
|
||||
WRONGLY for five sprints.** I told you garden_bed ships "full visible, rest `hide_render`". That is
|
||||
false. **glTF has no node-visibility flag and Blender's `hide_render` does not survive the export** —
|
||||
verified in three r175: `plants_full`, `plants_tattered` and `plants_dead` all arrive `visible: true`.
|
||||
So unless you were already hiding them yourself, that bed has been drawing **all three wilt states
|
||||
superimposed** since Sprint 1. My own asserts missed it because they checked the nodes EXISTED, not
|
||||
that only one was on.
|
||||
Fixed at source the only way that survives: the flag now rides in extras (userData does export —
|
||||
that much I did verify last sprint). **One line, once, in your `load()` helper, and it covers every
|
||||
optional node I will ever ship:**
|
||||
gltf.scene.traverse((o) => { if (o.userData?.hidden_by_default) o.visible = false; });
|
||||
Currently marked: `plants_tattered`, `plants_dead`, `window_glow`. e.test.js now asserts the FLAG,
|
||||
not the node. Sorry — that one's on me.
|
||||
|
||||
[E] 2026-07-17 — **night dressing (SPRINT6 §E-1), both for the night storms:**
|
||||
· `house_yardside_v1.glb` gains **`window_glow`** — an unlit emissive pane (needs no light, costs
|
||||
nothing while off) shipping `hidden_by_default`, plus a `window_light_anchor` empty if you want a
|
||||
warm ~2700 K PointLight spilling onto the grass under the eave. Flip it on for storm_02/03.
|
||||
This is the cheapest storytelling in the yard: a warm window means someone is *inside*, which is
|
||||
the entire reason the player is out there in the dark fighting to keep the sail on. An empty yard
|
||||
at night is just weather.
|
||||
· `models/textures/moon.png` — 256², disc + halo, alpha. **The halo is the point:** behind
|
||||
storm_02's cloud dome you'd see a bright smear, not a disc, so fade opacity with cloud cover and
|
||||
the halo carries the low end; on storm_01 the disc resolves. Lane C, it's a sprite on the dome —
|
||||
ignore it if the sky doesn't want one, no feelings.
|
||||
|
||||
[E] 2026-07-17 — **end cards (§E-2): `models/textures/card_win.jpg` + `card_gameover.jpg`, 1200×675.**
|
||||
Rendered from the game's OWN props rather than drawn — a gradient with a font on it says nothing; the
|
||||
gnome you failed to protect, lying in pieces exactly where he stood, says it without a word. They're a
|
||||
**diptych: identical camera, identical yard, and the only thing that changed is what the night did to
|
||||
it** — gnome standing in dawn light with a long shadow vs. in bits under flat grey, fence whole vs.
|
||||
holed with palings on the grass, bed green vs. dead. A player who sees both reads the difference
|
||||
faster than any headline. **The left third is deliberately empty — that's yours for the text**, Lane A,
|
||||
and the files are plain JPEGs so do whatever you like on top.
|
||||
|
||||
[E] 2026-07-17 — ⚠️ **worth knowing if you ever render from Blender: EEVEE's SHADOW pass is not
|
||||
byte-reproducible across processes in 5.1.** Two identical runs of an identical scene give different
|
||||
pixels. I chased this properly because the cards broke my determinism check: it is NOT PYTHONHASHSEED
|
||||
(6/6 identical with randomisation live), and it happens even with a hard 0° sun — minimal repro is a
|
||||
cube, a plane and a sun. The contact sheet dodges it only because thumbnails have no ground plane to
|
||||
receive a shadow (still byte-identical, 3/3 — that Sprint 1 claim stands).
|
||||
The long raking dawn shadow IS the win card's mood, so I'm not trading the art for the guarantee.
|
||||
Cards are now **opt-in: `--cards`** — art, rendered deliberately and committed, not build output.
|
||||
Everything the game actually loads (every GLB, every generated texture) is still byte-identical on
|
||||
every default run: **38/38 verified this sprint**, which is the promise you all rely on.
|
||||
Also, my own earlier check was lying to me: `ls a/*.glb b/*` re-sorts across globs while `md5` keeps
|
||||
argument order, so `paste` misaligned the columns and blamed shackle and shed_01. If you diff hashes,
|
||||
tag them with their filename.
|
||||
|
||||
[E] 2026-07-17 — §E-3 contact sheets refreshed (21 assets, 4×6). The **yard shots stay as they are** for
|
||||
now: `docs/yard_day.jpg` / `yard_night.jpg` are pre-balance and unrigged. Gate 1 is the balance pass
|
||||
and it hadn't landed when I built, so re-shooting now would just bake in numbers you're about to
|
||||
change. **Ping me when gate 1 is green and I'll reshoot both with a winnable rig actually in frame** —
|
||||
`tools/yardshot/shot_server.py` makes it a one-minute job now. (Lane A: your SPRINT6 list says lift
|
||||
`do_POST` into server.py and delete `tools/yardshot/` — the whole fix is ~25 lines, take it whenever.)
|
||||
|
||||
[E] 2026-07-17 — grass_atlas: still 0 refs, five sprints. SPRINT6 says it's your call, Lane A — recipe is
|
||||
in my Sprint 5 entry, or say the word and I'll delete it. Either is fine; carrying it isn't.
|
||||
|
||||
@ -11,6 +11,7 @@ Run:
|
||||
blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01
|
||||
blender -b -P tools/blender/build_yard_assets.py -- --no-verify
|
||||
blender -b -P tools/blender/build_yard_assets.py -- --no-debris
|
||||
blender -b -P tools/blender/build_yard_assets.py -- --cards # re-render the end cards
|
||||
|
||||
Outputs (resolved from this file, so no absolute home paths):
|
||||
web/world/models/*.glb nature, hardware, ref_capsule
|
||||
@ -119,6 +120,7 @@ PAL = {
|
||||
"gnome_hat": "#B33C36",
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"hail_ice": "#DCEAF2", # hailstone
|
||||
"window_warm": "#FFC98A", # someone is home
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
@ -165,6 +167,40 @@ def get_material(name, color_hex, roughness=0.7, metallic=0.0, opacity=1.0):
|
||||
return mat
|
||||
|
||||
|
||||
def hide_by_default(obj):
|
||||
"""Mark a node as "present but off until the game says otherwise".
|
||||
|
||||
glTF has NO standard node-visibility flag, and Blender's `hide_render` does
|
||||
not survive the export — verified in three r175: every node arrives
|
||||
`visible: true`. This shipped as a real bug from Sprint 1 to Sprint 6, with
|
||||
garden_bed rendering plants_full, plants_tattered AND plants_dead
|
||||
superimposed, and my own THREADS note claiming the opposite.
|
||||
|
||||
userData DOES survive, so the flag rides in extras and the consumer applies
|
||||
it in one line (see THREADS [E]):
|
||||
gltf.scene.traverse(o => { if (o.userData?.hidden_by_default) o.visible = false; });
|
||||
|
||||
hide_render is still set so Blender's own contact-sheet renders match the game.
|
||||
"""
|
||||
obj["hidden_by_default"] = True
|
||||
obj.hide_render = True
|
||||
return obj
|
||||
|
||||
|
||||
def _emissive(mat, color_hex, strength=1.0):
|
||||
"""Emissive on the Principled BSDF. glTF carries it as emissiveFactor (plus
|
||||
KHR_materials_emissive_strength above 1.0), so the pane reads with no light
|
||||
in the scene at all — which is the point on a black storm night."""
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
if not bsdf:
|
||||
return mat
|
||||
if "Emission Color" in bsdf.inputs:
|
||||
bsdf.inputs["Emission Color"].default_value = hex_to_rgba(color_hex)
|
||||
if "Emission Strength" in bsdf.inputs:
|
||||
bsdf.inputs["Emission Strength"].default_value = strength
|
||||
return mat
|
||||
|
||||
|
||||
def deselect_all_no_ops():
|
||||
"""Avoid bpy.ops.object.select_all — works without a proper context."""
|
||||
for o in bpy.data.objects:
|
||||
@ -672,6 +708,24 @@ def build_house_yardside(name):
|
||||
(win_x, -D / 2 + 0.02, win_z + win_h / 2), trim)],
|
||||
"window", root)
|
||||
|
||||
# Night dressing (SPRINT6 §Lane E-1). `window_glow` ships HIDDEN — Lane A
|
||||
# flips .visible on the night storms. It's an unlit emissive pane, so it
|
||||
# needs no light to read and costs nothing when it's off.
|
||||
#
|
||||
# This is the cheapest storytelling in the yard: a warm window means someone
|
||||
# is inside, which is the whole reason the player is out here in the dark
|
||||
# fighting to keep the sail on. An empty yard at night is just weather.
|
||||
glow_m = get_material("Mat_WindowGlow", PAL["window_warm"], 1.0)
|
||||
_emissive(glow_m, PAL["window_warm"], 2.4)
|
||||
glow = add_box("window_glow", (win_w - 0.10, 0.01, win_h - 0.10),
|
||||
(win_x, -D / 2 + 0.055, win_z + win_h / 2), glow_m,
|
||||
parent=root)
|
||||
hide_by_default(glow)
|
||||
glow["night_only"] = True
|
||||
e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2),
|
||||
root, size=0.2)
|
||||
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
|
||||
|
||||
# Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y).
|
||||
eave_y = -0.55
|
||||
fascia_z = H + 0.10
|
||||
@ -819,8 +873,9 @@ def build_garden_bed(name):
|
||||
node = join_group(parts, f"plants_{state}", root)
|
||||
if node:
|
||||
node["damage_state"] = state
|
||||
# Only `full` ships visible; Lane A swaps by toggling these.
|
||||
node.hide_render = (state != "full")
|
||||
# Only `full` starts on; Lane A swaps by toggling .visible.
|
||||
if state != "full":
|
||||
hide_by_default(node)
|
||||
stamp(root, name, "garden")
|
||||
root["states"] = "full,tattered,dead"
|
||||
root["bed_size"] = f"{W}x{D}"
|
||||
@ -1622,6 +1677,167 @@ def build_hail_and_shred_atlases():
|
||||
return [p1, p2]
|
||||
|
||||
|
||||
def build_moon_texture():
|
||||
"""Moon disc + halo for the night storms (SPRINT6 §Lane E-1).
|
||||
|
||||
The halo is the point, not the disc. On storm_02 the moon sits behind a
|
||||
cloud dome and what you'd actually see is a bright smear; on storm_01 the
|
||||
disc resolves. One sprite does both — Lane C fades opacity with cloud cover
|
||||
and the halo carries the low end.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
SIZE = 256
|
||||
Y, X = np.mgrid[0:SIZE, 0:SIZE]
|
||||
c = (SIZE - 1) / 2.0
|
||||
nx, ny = (X - c) / c, (Y - c) / c
|
||||
r = np.sqrt(nx * nx + ny * ny)
|
||||
|
||||
R_DISC = 0.34
|
||||
disc = np.clip((R_DISC - r) / 0.02, 0, 1) # soft-edged disc
|
||||
halo = np.exp(-((r - R_DISC) ** 2) / 0.045) * 0.55 # the bit that survives cloud
|
||||
halo = np.where(r > R_DISC, halo, 0.0)
|
||||
|
||||
# Maria: a few soft dark blotches, seeded. Craters at this size are a lie —
|
||||
# what you see from a back yard is patches.
|
||||
shade = np.ones_like(r)
|
||||
mrng = rng_for("moon_maria")
|
||||
for _ in range(6):
|
||||
mx, my = mrng.uniform(-0.18, 0.18), mrng.uniform(-0.18, 0.18)
|
||||
mr = mrng.uniform(0.06, 0.13)
|
||||
d = np.sqrt((nx - mx) ** 2 + (ny - my) ** 2)
|
||||
shade -= np.exp(-(d ** 2) / (mr ** 2)) * mrng.uniform(0.06, 0.13)
|
||||
shade = np.clip(shade, 0.72, 1.0)
|
||||
|
||||
a = np.clip(disc + halo, 0, 1)
|
||||
lum = np.clip(np.where(disc > 0, shade, 1.0), 0, 1)
|
||||
img = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
img[:, :, 0] = lum * 0.96
|
||||
img[:, :, 1] = lum * 0.97
|
||||
img[:, :, 2] = lum
|
||||
img[:, :, 3] = a
|
||||
p, kb = save_png(img, "moon")
|
||||
print(f" moon.png {SIZE}x{SIZE}, disc+halo, {kb} KB")
|
||||
return p
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# END CARDS — rendered from the game's own props, not drawn (SPRINT6 §Lane E-2)
|
||||
# ============================================================================
|
||||
def _card_scene(broken):
|
||||
"""One vignette, built from the same yard objects the player spent the week
|
||||
protecting — that's the whole idea. A gradient with a font on it says
|
||||
nothing; the gnome you failed to protect, lying in pieces, says it without a
|
||||
word."""
|
||||
reset_to_empty()
|
||||
grass = get_material("Mat_Grass", "#4A6B36" if broken else "#5C8A3A", 1.0)
|
||||
add_box("ground", (60, 60, 0.4), (0, 0, -0.2), grass)
|
||||
|
||||
if broken:
|
||||
gnome = build_garden_gnome_01_broken("gnome")
|
||||
gnome.location = (0.82, -0.78, 0)
|
||||
gnome.rotation_mode = 'XYZ'
|
||||
gnome.rotation_euler = (0, 0, math.radians(-24))
|
||||
fence = build_fence_panel_snapped("fence")
|
||||
fence.location = (-0.35, 2.6, 0)
|
||||
bin_ = build_wheelie_bin_01("bin") # blown over, lid flung open
|
||||
bin_.location = (2.9, 1.5, 0.34)
|
||||
bin_.rotation_mode = 'XYZ'
|
||||
bin_.rotation_euler = (math.radians(-96), 0, math.radians(38))
|
||||
bed = build_garden_bed("bed")
|
||||
bed.location = (-3.4, 1.2, 0)
|
||||
for o in bpy.data.objects: # a loss shows the dead bed
|
||||
if o.name == "plants_full":
|
||||
o.hide_render = True
|
||||
elif o.name == "plants_dead":
|
||||
o.hide_render = False
|
||||
else:
|
||||
gnome = build_garden_gnome_01("gnome")
|
||||
gnome.location = (0.82, -0.78, 0)
|
||||
fence = build_fence_panel("fence")
|
||||
fence.location = (-0.35, 2.6, 0)
|
||||
post = build_fence_post("post")
|
||||
post.location = (0.85, 2.6, 0)
|
||||
bed = build_garden_bed("bed")
|
||||
bed.location = (-3.4, 1.2, 0)
|
||||
shed = build_shed_01("shed")
|
||||
shed.location = (3.6, 2.2, 0)
|
||||
|
||||
|
||||
def build_end_cards():
|
||||
"""NOT part of the default build — pass `--cards`.
|
||||
|
||||
EEVEE's SHADOW rendering is not byte-reproducible across processes in
|
||||
Blender 5.1: two identical runs of the same scene give slightly different
|
||||
pixels. Measured, with a minimal cube-plus-plane repro, and it happens even
|
||||
with a hard (0-degree) sun. The contact sheet escapes it only because its
|
||||
thumbnails have no ground plane to receive a shadow — verified still
|
||||
byte-identical 3/3.
|
||||
|
||||
The long raking dawn shadow IS the win card's mood, so dropping shadows to
|
||||
win determinism would be trading the art for the guarantee. Instead these
|
||||
are treated as what they are: art, rendered deliberately and committed, not
|
||||
build output. Everything the game actually loads — every GLB and every
|
||||
generated texture — stays byte-identical on every run, which is the promise
|
||||
other lanes rely on.
|
||||
"""
|
||||
out = []
|
||||
for name, broken, warm in (("card_win", False, True),
|
||||
("card_gameover", True, False)):
|
||||
_card_scene(broken)
|
||||
scn = bpy.context.scene
|
||||
scn.render.engine = 'BLENDER_EEVEE'
|
||||
scn.render.resolution_x, scn.render.resolution_y = 1200, 675
|
||||
scn.render.image_settings.file_format = 'JPEG'
|
||||
scn.render.image_settings.quality = 88
|
||||
scn.world = bpy.data.worlds.new(f"W_{name}")
|
||||
scn.world.use_nodes = True
|
||||
bg = scn.world.node_tree.nodes.get("Background")
|
||||
if bg:
|
||||
# Dawn after a night you survived, vs the flat grey of a morning you
|
||||
# have to explain to a client.
|
||||
bg.inputs[0].default_value = ((0.92, 0.55, 0.32, 1.0) if warm
|
||||
else (0.34, 0.37, 0.41, 1.0))
|
||||
# Low strength on purpose. The world is a huge ambient fill, and at
|
||||
# 1.15 it flooded both cards flat — the sun stopped casting anything
|
||||
# and dawn read as fog. Keep the fill down and let the sun do it.
|
||||
bg.inputs[1].default_value = 0.30 if warm else 0.42
|
||||
|
||||
bpy.ops.object.light_add(type='SUN', location=(-6, -8, 4))
|
||||
sun = _active()
|
||||
sun.data.energy = 7.0 if warm else 2.6
|
||||
sun.data.angle = math.radians(2 if warm else 40) # crisp dawn vs overcast
|
||||
sun.data.color = (1.0, 0.78, 0.52) if warm else (0.82, 0.86, 0.92)
|
||||
sun.rotation_mode = 'XYZ'
|
||||
# Low and raking on the win card: the long shadows are the mood.
|
||||
sun.rotation_euler = ((math.radians(74), 0, math.radians(-52)) if warm
|
||||
else (math.radians(28), 0, math.radians(30)))
|
||||
|
||||
bpy.ops.object.camera_add(location=(0, 0, 0))
|
||||
cam = _active()
|
||||
cam.data.lens = 50
|
||||
scn.camera = cam
|
||||
# IDENTICAL camera on both cards, deliberately. They're a diptych: same
|
||||
# yard, same framing, and the only thing that changed overnight is what
|
||||
# happened to it. A player who sees both reads the difference instantly,
|
||||
# which no amount of headline text would do as fast. Low and close, so
|
||||
# the gnome is the subject; the left third stays empty for Lane A's text.
|
||||
cam.location = (2.00, -2.86, 0.94)
|
||||
cam.rotation_mode = 'XYZ'
|
||||
target = Vector((0.66, -0.42, 0.30))
|
||||
cam.rotation_euler = (target - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
|
||||
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
||||
out_path = os.path.join(TEXTURES_DIR, f"{name}.jpg")
|
||||
scn.render.filepath = out_path
|
||||
bpy.ops.render.render(write_still=True)
|
||||
kb = os.path.getsize(out_path) // 1024
|
||||
print(f" {name}.jpg{' ' * max(1, 18 - len(name))}1200x675, from props, {kb} KB")
|
||||
out.append(out_path)
|
||||
reset_to_empty()
|
||||
return out
|
||||
|
||||
|
||||
def build_grass_atlas():
|
||||
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
|
||||
python) and saved through bpy's image API. Lane A instances quads with this."""
|
||||
@ -1696,6 +1912,7 @@ ASSETS = [
|
||||
dict(name="house_yardside", fn=build_house_yardside,
|
||||
dims=((9.0, 9.5), (0.8, 1.6), (2.8, 3.3)),
|
||||
nodes=["wall", "door", "window", "roof", "fascia", "gutter",
|
||||
"window_glow", "window_light_anchor",
|
||||
"fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]),
|
||||
dict(name="shed_01", fn=build_shed_01,
|
||||
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
|
||||
@ -2058,18 +2275,20 @@ def make_contact_sheet(thumbs):
|
||||
def parse_args():
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
only, no_verify, no_debris = None, False, False
|
||||
only, no_verify, no_debris, cards = None, False, False, False
|
||||
if "--only" in argv:
|
||||
only = set(argv[argv.index("--only") + 1].split(","))
|
||||
if "--no-verify" in argv:
|
||||
no_verify = True
|
||||
if "--no-debris" in argv:
|
||||
no_debris = True
|
||||
return only, no_verify, no_debris
|
||||
if "--cards" in argv:
|
||||
cards = True
|
||||
return only, no_verify, no_debris, cards
|
||||
|
||||
|
||||
def main():
|
||||
only, no_verify, no_debris = parse_args()
|
||||
only, no_verify, no_debris, cards = parse_args()
|
||||
print("\n" + "=" * 72)
|
||||
print("SHADES yard asset factory — Lane E")
|
||||
print(f"Blender {bpy.app.version_string} repo: {REPO_ROOT}")
|
||||
@ -2081,6 +2300,9 @@ def main():
|
||||
build_sail_textures()
|
||||
build_pond_textures()
|
||||
build_hail_and_shred_atlases()
|
||||
build_moon_texture()
|
||||
if cards:
|
||||
build_end_cards()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
@ -280,6 +280,28 @@ export default async function run(t) {
|
||||
}
|
||||
});
|
||||
|
||||
// glTF has no node-visibility flag and Blender's hide_render does NOT survive
|
||||
// the export — every node arrives visible:true. That shipped as a real bug
|
||||
// from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead
|
||||
// superimposed, and the asserts missed it because they only checked the nodes
|
||||
// EXISTED. So the flag rides in extras, and this pins the flag, not the node.
|
||||
t.test('optional nodes carry hidden_by_default — hide_render does not export', () => {
|
||||
const bed = loaded.get('garden_bed')?.scene;
|
||||
assert(bed, 'garden_bed did not load');
|
||||
const on = ['plants_full'], off = ['plants_tattered', 'plants_dead'];
|
||||
for (const n of on) {
|
||||
assert(!bed.getObjectByName(n).userData?.hidden_by_default,
|
||||
`${n} should start visible — it's the default state`);
|
||||
}
|
||||
for (const n of off) {
|
||||
assert(bed.getObjectByName(n).userData?.hidden_by_default === true,
|
||||
`${n} must carry hidden_by_default or the bed renders all three wilt states at once`);
|
||||
}
|
||||
const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow');
|
||||
assert(glow?.userData?.hidden_by_default === true,
|
||||
'window_glow must carry hidden_by_default or the house is lit at noon');
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
web/world/models/textures/card_gameover.jpg
Normal file
BIN
web/world/models/textures/card_gameover.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
web/world/models/textures/card_win.jpg
Normal file
BIN
web/world/models/textures/card_win.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
web/world/models/textures/moon.png
Normal file
BIN
web/world/models/textures/moon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Loading…
Reference in New Issue
Block a user