This repository was archived by the owner on Aug 14, 2026. It is now read-only.
forked from TeJota1337/DramaticShapeVoxelMod
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathTerrainAtlas.lua
More file actions
649 lines (613 loc) · 26.6 KB
/
Copy pathTerrainAtlas.lua
File metadata and controls
649 lines (613 loc) · 26.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
-- Voxel world mode: the texture terrain samples.
--
-- Voxel terrain is textured from the tileset atlas, so a map's colors have
-- to live IN that atlas. Two of the three color paths already do:
--
-- RED++ TileRenderer.new bakes a fully recolored per-map atlas
-- (getGbcAtlas) and hands it over as renderer.image -- nothing
-- to do here, true GBC terrain color comes through untouched.
-- trueColor a mod's full-color atlas is already its own colors.
--
-- The SGB modes are the gap. There the atlas is raw 4-shade grayscale and
-- the color normally arrives as a screen-space shade-remap pass over
-- rectangular zones (PaletteFX) -- which has no meaning once the ground is
-- geometry rather than a rectangle. So bake instead: one atlas copy per
-- (atlas, palette), remapped through the same cutoffs the shader uses.
--
-- A map has exactly one world palette, so this is a handful of 128x48
-- images for a whole session, built once and cached.
--
-- ANIMATED TILES (water, flowers) are the other thing this file owns. The
-- 2D path animates them by OVERDRAWING the animated cells on top of the
-- static tile layer each frame, which a single static mesh has no
-- equivalent of -- the geometry samples one texture and that is that. So
-- animate the texture: rewrite the animated tile's slot in a private copy
-- of the atlas whenever the step advances, and every instance of that tile
-- across the whole mesh moves at once. Which is what the Game Boy does in
-- the first place (home/vcopy.asm rewrites the tile's VRAM bytes); the 2D
-- overdraw is the port's workaround, not the original.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local TileRenderer = require("src.render.TileRenderer")
local PaletteFX = require("src.render.PaletteFX")
local ImageCache = V.require("ImageCache")
local TerrainAtlas = {}
local cache = {}
local cacheData = {} -- the pixels behind the atlases we baked ourselves
local animated = {} -- key -> one map's private, mutable animated atlas
-- false = given up on; nil = not built (or retrying)
local attempts = {} -- key -> consecutive failures, for the retry budget
-- A failure that might not repeat -- a driver refusing one readback, an
-- asset briefly unreadable, a patch that threw once mid-reload -- must not
-- cost the animation for the rest of the session. It used to: the key was
-- condemned to `false` on the first miss and nothing ever rebuilt it, so
-- water stopped moving and stayed stopped until a hot reload.
--
-- Retry a few times, then give up for good so a genuinely broken atlas is
-- not rebuilt on every frame forever.
local MAX_ATTEMPTS = 3
local function attemptFailed(key)
local n = (attempts[key] or 0) + 1
attempts[key] = n
if n >= MAX_ATTEMPTS then return false end -- condemn it
return nil -- rebuild next frame
end
local function paletteKey(colors)
local parts = {}
for i = 1, 4 do
local c = colors[i]
parts[i] = c and (c[1] .. "," .. c[2] .. "," .. c[3]) or "-"
end
return table.concat(parts, ";")
end
-- The atlas image `map`'s terrain should sample, given the 4-color world
-- palette it sits under (nil to leave the atlas as-is). Falls back to the
-- renderer's own image whenever a bake is impossible -- headless, or no
-- pixel access -- which just means grayscale terrain rather than no
-- terrain.
-- The static atlas for `map` under `colors`: the answer this file gave
-- before animation existed, and the base every animated frame is patched
-- over. Returns the image and, when we baked it ourselves, its pixels.
local function staticAtlas(map, colors)
local renderer = map.renderer
local base = renderer and renderer.image
if not base then return nil end
-- already true color: RED++'s baked per-map atlas, or a mod's own art
if not colors or renderer.gbcAtlas or map.tileset.trueColor then
return base, false
end
if not (love.image and love.image.newImageData) then return base, false end
local path = map.tileset.image
local key = path .. "#" .. paletteKey(colors)
if cache[key] ~= nil then return cache[key] or base, cacheData[key] end
local data
local ok, img = pcall(function()
local src = ImageCache.get(path)
local w, h = src:getDimensions()
local out = love.image.newImageData(w, h)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = src:getPixel(x, y)
r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
out:setPixel(x, y, r, g, b, a)
end
end
local image = love.graphics.newImage(out)
image:setFilter("nearest", "nearest")
data = out
return image
end)
cache[key] = ok and img or false
cacheData[key] = (ok and data) or false
return cache[key] or base, cacheData[key]
end
-- ------------------------------------------------------------ animation --
-- The four GB shades as the ORIGINAL art carries them, by the same cutoffs
-- TileRenderer.recolorSample splits on -- so a shade learned here and a
-- shade recolored there are the same shade.
local function shadeOf(r)
if r > 0.83 then return 1 end
if r > 0.5 then return 2 end
if r > 0.17 then return 3 end
return 4
end
-- How this atlas recolored one tile, learned by reading the tile's slot in
-- the raw art and in the finished atlas side by side: shade -> the colour
-- it became.
--
-- Learned rather than recomputed because the two recolour paths do not
-- share a rule -- SGB bakes one world palette over everything, RED++ picks
-- a palette group per tile GRAPHIC -- and a flower frame arrives as its own
-- little grayscale file that never went through either. Asking "what
-- happened to the tile I am replacing" gets the right answer from both
-- without this file knowing which one ran.
local function learnShades(raw, baked, tile, perRow)
local sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
local map = {}
for y = 0, 7 do
for x = 0, 7 do
local k = shadeOf(raw:getPixel(sx + x, sy + y))
if not map[k] then
local r, g, b, a = baked:getPixel(sx + x, sy + y)
map[k] = { r, g, b, a }
end
end
end
return map
end
local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
-- One animated entry's tile slot, written into `out` at step `step`.
local function patch(out, entry, spec, step)
local perRow, tile = entry.perRow, spec.tile
local dx, dy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
if spec.kind == "hshift" then
-- the water rotate (the asm's rrca/rlca run): the tile's own pixels,
-- rolled sideways. Read from the UNANIMATED base, or each step would
-- compound on the last one's shift.
local o = spec.offsets[step % #spec.offsets + 1]
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = entry.base:getPixel(dx + x, dy + y)
out:setPixel(dx + (x + o) % 8, dy + y, r, g, b, a)
end
end
elseif spec.kind == "frames" then
local path = spec.images[spec.sequence[step % #spec.sequence + 1]]
if not path then return end
local ok, frame = pcall(ImageCache.get, path)
if not ok or not frame then return end
local shades = entry.shades and entry.shades[tile]
-- a `cut` tile is the flower billboard's slot (Structures'
-- buildFlowers): only the frame's darkest tones AND what they
-- enclose stay opaque -- the round-scenery hull's rule, flooding
-- the frame border through every non-dark pixel so the pale petal
-- insides survive with the outline. The true background is keyed
-- to alpha; nothing else samples this slot (the ground under a
-- flower is synthesized), and the shader's discard is what lets
-- the billboard's silhouette change per frame under geometry that
-- never moves.
local mask = nil
if entry.cut and entry.cut[tile] then
local dark, reach, stack = {}, {}, {}
for y = 0, 7 do
for x = 0, 7 do
local r, _, _, a = frame:getPixel(x, y)
if a > 0 and shadeOf(r) >= 3 then dark[y * 8 + x] = true end
end
end
for i = 0, 7 do
for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do
if not dark[s] and not reach[s] then
reach[s] = true
stack[#stack + 1] = s
end
end
end
while #stack > 0 do
local p = table.remove(stack)
local px, py = p % 8, math.floor(p / 8)
for _, d in ipairs(DIRS4) do
local nx, ny = px + d[1], py + d[2]
if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then
local ni = ny * 8 + nx
if not dark[ni] and not reach[ni] then
reach[ni] = true
stack[#stack + 1] = ni
end
end
end
end
mask = {}
for i = 0, 63 do mask[i] = dark[i] or not reach[i] end
end
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frame:getPixel(x, y)
if mask and not mask[y * 8 + x] then
out:setPixel(dx + x, dy + y, 0, 0, 0, 0)
else
local col = shades and shades[shadeOf(r)]
if col and a > 0 then r, g, b = col[1], col[2], col[3] end
out:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
end
end
-- The animation specs this file can serve: the two that rewrite a tile's
-- pixels. "toggle" (the spinner-puzzle blur) is a whole-atlas swap gated on
-- a gameplay state, and is left to the 2D path -- voxel mode does not draw
-- the spinner rooms' tile layer any differently for it.
local function specsFor(tileset)
local declared = tileset.animatedTiles
or TileRenderer.defaultAnimatedTiles(tileset)
local out = nil
for _, spec in ipairs(declared or {}) do
local usable = spec.tile
and ((spec.kind == "hshift" and spec.offsets and #spec.offsets > 0)
or (spec.kind == "frames" and spec.images and spec.sequence
and #spec.sequence > 0))
if usable then
out = out or {}
out[#out + 1] = spec
end
end
return out
end
-- Pixels back off a texture the engine built on the GPU and kept no copy
-- of. LOVE 11 hands out no ImageData for an Image, so the only route is a
-- round trip: draw it 1:1 into a canvas and read that back.
--
-- This runs inside the world pass, with the pipeline's own canvas bound, so
-- the previous target is captured and put back rather than unbound -- the
-- usual setCanvas() would drop the rest of the frame on the floor. One
-- readback per map, cached with the entry it feeds; the atlas is a couple
-- of hundred pixels square, so the GPU sync costs far less than the mesh
-- build it happens alongside. Every step is guarded: a driver that refuses
-- canvas readback costs the animation and nothing else.
local function readback(image)
if not (image and love.graphics and love.graphics.newCanvas
and love.graphics.getCanvas) then
return nil
end
local prev = love.graphics.getCanvas()
local ok, data = pcall(function()
local w, h = image:getDimensions()
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
-- so the atlas would be drawn into a texture 2.75x its size and read
-- back magnified -- and every tile coordinate below, which counts in
-- eights from the top-left, would land somewhere between two tiles.
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 0)
-- straight copy: no blending against the cleared target, no tint from
-- whatever colour the pass left set, or the atlas comes back wrong
love.graphics.setBlendMode("replace", "premultiplied")
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, 0, 0)
love.graphics.setBlendMode("alpha", "alphamultiply")
-- LOVE refuses newImageData on the currently-active canvas, so the
-- previous target has to come back BEFORE the read, not just after
love.graphics.setCanvas(prev)
local out = canvas:newImageData()
if canvas.release then canvas:release() end
return out
end)
pcall(love.graphics.setCanvas, prev)
return ok and data or nil
end
-- RED++'s per-map atlas, rebuilt on the CPU.
--
-- This is the case that has no pixels anywhere: `getGbcAtlas` bakes one
-- ImageData per map, hands the texture to the renderer and drops the
-- pixels on the floor. Without them the animated tiles cannot be patched,
-- which is why water and flowers stood still under RED++ and nowhere else.
--
-- The readback below can recover them from the texture, but it is at the
-- mercy of whether the driver will read a canvas back, and it costs a GPU
-- sync mid-frame. Everything the engine baked FROM is public, so bake it
-- again instead: the raw art, the per-tile palette group, the group's
-- colours, and the same recolorSample cutoffs. Deterministic, no driver
-- involved, and it can be tested without a GPU.
--
-- It does mirror engine logic and could drift from getGbcAtlas if that
-- changes -- the readback stays behind it as the exact-but-fragile route.
local function gbcPixels(map)
local renderer, tileset = map.renderer, map.tileset
local data = renderer and renderer.data
if not (data and tileset and love.image and love.image.newImageData) then
return nil
end
local ok, out = pcall(function()
local groupColors = PaletteFX.worldGroupColors(data, tileset.id, map.id, nil)
if not groupColors then return nil end
local src = ImageCache.get(tileset.image)
local iw, ih = src:getDimensions()
local perRow = tileset.tilesPerRow or 16
local total = (iw / 8) * (ih / 8)
local dst = love.image.newImageData(iw, ih)
local function bake(from, to, colors)
local sxo, syo = (from % perRow) * 8, math.floor(from / perRow) * 8
local dxo, dyo = (to % perRow) * 8, math.floor(to / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local r, g, b, a = src:getPixel(sxo + px, syo + py)
r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
dst:setPixel(dxo + px, dyo + py, r, g, b, a)
end
end
end
local tileColors = {}
for t = 0, total - 1 do
local colors = tileColors[t]
if colors == nil then
local group = PaletteFX.worldGroupAt(tileset.id, map.id, t)
colors = (group and groupColors[group + 1]) or false
tileColors[t] = colors
end
bake(t, t, colors)
end
-- duplicate-tile aliases: the same graphic baked into a spare slot
-- under a second palette group, so cells drawing the alias colour apart
for _, al in ipairs(PaletteFX.TILE_ALIASES
and PaletteFX.TILE_ALIASES[map.id] or {}) do
if al.alias < total then
bake(al.tile, al.alias, groupColors[al.group + 1])
end
end
return dst
end)
return ok and out or nil
end
-- The pixels behind the atlas texture the engine is drawing with, for the
-- frames where we did not bake one ourselves (staticAtlas returns `false`
-- for its own bake whenever the palette is absent, RED++ already baked, or
-- the tileset is trueColor).
--
-- TileRenderer.atlasImageData is the engine's own accessor for exactly this
-- and is preferred wherever the build offers it -- but like the sibling
-- clock TileRenderer.animFrame it is an OPTIONAL seam, and a build without
-- it has to cost us the animation, not the whole render pipeline. Reading
-- it unguarded is what took the pass down for the session.
--
-- Without the seam the pixels are still recoverable, by two different
-- routes. An atlas neither we nor RED++ replaced is the tileset art itself,
-- so the art on disk IS what it was built from. RED++'s per-map bake exists
-- only on the GPU -- getGbcAtlas throws its ImageData away once the texture
-- is made -- so that one has to come back off the texture (readback below).
local function rendererPixels(map)
local renderer = map.renderer
if not renderer then return nil end
if TileRenderer.atlasImageData then
local ok, data = pcall(TileRenderer.atlasImageData, renderer)
if ok and data then return data end
end
if renderer.gbcAtlas then
return gbcPixels(map) or readback(renderer.image)
end
local ok, data = pcall(ImageCache.get, map.tileset.image)
return ok and data or nil
end
-- The engine's tile-animation clock: TileRenderer's 60Hz counter, by
-- whatever route this build offers.
--
-- It matters that this is the ENGINE's number and not one of our own. The
-- 2D tile layer and this texture animate the same water off the same
-- counter, so toggling voxel mode mid-cycle continues the animation instead
-- of restarting or jumping it. A clock of our own would free-run against
-- the one the flat path is drawing from.
--
-- 1. TileRenderer.animFrame(), where the build exports it.
-- 2. else the counter itself, off tick()'s upvalues. It is a plain local
-- in that module, so this is exact and live -- the same number, not an
-- approximation of it. Reading engine internals is what this mod's
-- "engine_internals" permission is declared for, and this one is
-- read-only and entirely optional.
-- 3. else wall time in 60Hz steps. Free-running, but the water moves,
-- which beats a frozen pond. Derived from absolute time rather than
-- accumulated deltas because animate() is called once per map in the
-- neighbourhood, so a per-call accumulator would run several times
-- too fast.
local clockUpvalue = nil -- nil = not looked for yet, false = absent
local function findClockUpvalue()
if not (debug and debug.getupvalue) then return false end
if type(TileRenderer.tick) ~= "function" then return false end
for i = 1, 32 do
local ok, name, value = pcall(debug.getupvalue, TileRenderer.tick, i)
if not (ok and name) then break end
if name == "animFrame" and type(value) == "number" then return i end
end
return false
end
local function animFrame()
if TileRenderer.animFrame then
local ok, f = pcall(TileRenderer.animFrame)
if ok and type(f) == "number" then return f end
end
if clockUpvalue == nil then clockUpvalue = findClockUpvalue() end
if clockUpvalue then
local ok, _, value = pcall(debug.getupvalue, TileRenderer.tick, clockUpvalue)
if ok and type(value) == "number" then return value end
end
if love.timer and love.timer.getTime then
return math.floor(love.timer.getTime() * 60)
end
return 0
end
TerrainAtlas._animFrame = animFrame -- named for the suite
-- false = this can never work and asking again is waste; nil = it did not
-- work THIS time and might next. The caller latches the first and retries
-- the second (see attemptFailed).
local function newEntry(map, base, baked)
local tileset = map.tileset
local specs = specsFor(tileset)
if not specs then return false end -- nothing on this tileset animates
if not (love.image and love.image.newImageData
and base.replacePixels) then
return false -- no pixel access on this machine
end
-- the pixels the atlas texture was built from: our own SGB bake when we
-- made one, else whatever the engine's renderer is drawing with. A
-- readback can fail for one frame and work the next, so this is a
-- retryable miss rather than a verdict.
local src = baked or rendererPixels(map)
if not src then return nil end
local ok, entry = pcall(function()
local w, h = src:getDimensions()
-- A PRIVATE copy, always. The base may be the engine's own atlas, and
-- the 2D path draws the static tile from it and overdraws the animated
-- cell on top -- rewriting the slot underneath would animate the tile
-- twice over there and corrupt every still frame of it.
local data = love.image.newImageData(w, h)
data:paste(src, 0, 0, 0, 0, w, h)
local image = love.graphics.newImage(data)
image:setFilter("nearest", "nearest")
local e = { base = src, data = data, image = image, specs = specs,
perRow = tileset.tilesPerRow or 16, step = nil }
-- the frame files are raw grayscale; learn what the atlas did to the
-- tile each one stands in for, so they land on the same colours
local recolored = baked or (map.renderer and map.renderer.gbcAtlas)
if recolored and not tileset.trueColor then
local raw = ImageCache.get(tileset.image)
e.shades = {}
for _, spec in ipairs(specs) do
if spec.kind == "frames" then
e.shades[spec.tile] = learnShades(raw, src, spec.tile, e.perRow)
end
end
end
-- a frame-animated tile that resolved to the `flower` class stands as
-- a 1px billboard (Structures.buildFlowers), and its slot in THIS
-- copy carries only each frame's dark tones with the rest keyed to
-- alpha -- see patch(). Gated on the resolved shape so a profile that
-- pins the tile to something solid keeps a fully opaque slot, and on
-- the tileset art being readable: without pixels Structures cannot
-- have stood the billboard up (or synthesized the ground), and an
-- alpha-keyed slot under an ordinary flat quad is a hole in the world.
for _, spec in ipairs(specs) do
if spec.kind == "frames" then
local okCut, isFlower = pcall(function()
local okA, art = pcall(ImageCache.get, tileset.image)
if not (okA and art and art.getPixel) then return false end
local sh = V.require("TileShape").forMap(map)[spec.tile]
return sh ~= nil and sh.class == "flower"
end)
if okCut and isFlower then
e.cut = e.cut or {}
e.cut[spec.tile] = true
end
end
end
return e
end)
return (ok and entry) or false
end
-- The atlas for this frame: the static one when nothing on this tileset
-- animates (or anything at all goes wrong), else a private copy with the
-- animated slots rewritten to the current step. Repatched only when the
-- step actually changes -- three times a second, ~130 pixels of work.
function TerrainAtlas.animate(map, colors, base, baked)
-- RED++ bakes a per-MAP atlas, so its animated copy is per map too and
-- has to be evicted with the meshes (setLive below); the shared paths key
-- on the tileset and palette alone, which is bounded by how many of those
-- exist at all.
local perMap = map.renderer and map.renderer.gbcAtlas and map.id or nil
local key = map.tileset.image .. "#a#" .. paletteKey(colors or {})
.. (perMap or "")
local entry = animated[key]
if entry == nil then
entry = newEntry(map, base, baked)
if entry then
entry.mapId = perMap
animated[key] = entry
else
-- false from newEntry is a verdict (nothing animates on this tileset,
-- no pixel access at all); nil is a miss that may not repeat
animated[key] = (entry == false) and false or attemptFailed(key)
end
end
if not entry then return nil end
local frame = animFrame()
-- one number for the whole entry: every spec's own step, folded together,
-- so a repatch happens when ANY of them turns over
local step = 0
for i, spec in ipairs(entry.specs) do
local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence
step = step + (math.floor(frame / (spec.period or 20)) % n) * (16 ^ i)
end
if step ~= entry.step then
entry.step = step
local ok = pcall(function()
for _, spec in ipairs(entry.specs) do
local n = spec.kind == "hshift" and #spec.offsets or #spec.sequence
patch(entry.data, entry, spec,
math.floor(frame / (spec.period or 20)) % n)
end
entry.image:replacePixels(entry.data)
end)
if not ok then
-- drop the entry rather than condemning the key: the next frame
-- rebuilds and tries again, and attemptFailed gives up eventually
animated[key] = attemptFailed(key)
return nil
end
end
-- Only a frame that got all the way here counts as healthy. Clearing the
-- budget on a successful BUILD instead would never let it run out: an
-- entry that builds fine and fails on upload would rebuild every frame,
-- forever, which is worse than either animating or giving up.
if attempts[key] then attempts[key] = nil end
return entry.image
end
function TerrainAtlas.forMap(map, colors)
local base, baked = staticAtlas(map, colors)
if not base then return nil end
return TerrainAtlas.animate(map, colors, base, baked) or base
end
-- The image a character model should texture from under an SGB palette.
--
-- In the 2D SGB modes, sprites are colorized by the screen-space
-- shade-remap shader at blit time -- the sheet itself stays grayscale. The
-- voxel canvas composites 1:1 with no shader pass, so a model textured
-- straight from the sheet renders in raw DMG grays (a black-and-gray
-- character standing in a colored room). Bake instead, exactly like the
-- terrain above: one recolored sheet per (sheet, palette), remapped
-- through the same cutoffs the shader uses, alpha preserved so OBJ color
-- 0 stays transparent. Falls back to nil (caller keeps its texture) when
-- pixels are unreachable.
function TerrainAtlas.forSprite(path, colors)
if not (colors and love.image and love.image.newImageData) then
return nil
end
local key = "spr:" .. path .. "#" .. paletteKey(colors)
if cache[key] ~= nil then return cache[key] or nil end
local ok, img = pcall(function()
local src = ImageCache.get(path)
local w, h = src:getDimensions()
local out = love.image.newImageData(w, h)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = src:getPixel(x, y)
r, g, b, a = TileRenderer.recolorSample(r, g, b, a, colors)
out:setPixel(x, y, r, g, b, a)
end
end
local image = love.graphics.newImage(out)
image:setFilter("nearest", "nearest")
return image
end)
cache[key] = ok and img or false
return cache[key] or nil
end
-- Release the animated copies of maps outside `live` (a set of map ids),
-- the same neighbourhood ChunkMesher bounds its meshes to and called from
-- the same place. Only the per-map RED++ copies are held this way; the rest
-- are keyed by tileset and palette, of which a session sees a handful.
-- Without this a cross-region trek accumulates one atlas and one texture
-- per map ever entered, and each pins the engine's own baked ImageData
-- alive behind it.
function TerrainAtlas.setLive(live)
for key, entry in pairs(animated) do
if entry and entry.mapId and not live[entry.mapId] then
if entry.image and entry.image.release then
pcall(entry.image.release, entry.image)
end
animated[key] = nil
end
end
end
function TerrainAtlas.invalidate()
cache = {}
cacheData = {}
attempts = {}
for _, entry in pairs(animated) do
if entry and entry.image and entry.image.release then
pcall(entry.image.release, entry.image)
end
end
animated = {}
end
Assets.register(TerrainAtlas.invalidate)
return TerrainAtlas