mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:10:49 +02:00
update 2d shiny sprites and shiny animation
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
-- Every species' battle palette, normal beside shiny, as one HTML page.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Writes
|
||||
-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html
|
||||
--
|
||||
-- ------- what it is actually showing
|
||||
--
|
||||
-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's
|
||||
-- values for a model's texels, and it is already checked against the Python
|
||||
-- that produced it. This is the other end: what those values become after
|
||||
-- ShinyPics puts them through the engine's four-shade battle palette, which
|
||||
-- is where the flat art gets its colour and the only place a mistake there
|
||||
-- shows up.
|
||||
--
|
||||
-- Two rules are visible in the output and both were bugs first:
|
||||
--
|
||||
-- * shade 1 and shade 4 never move. They are the shared paper (255,239,255)
|
||||
-- and the shared ink (25,16,16), not colours anybody chose for this
|
||||
-- animal, and sliding them turned shiny Golbat's white navy.
|
||||
-- * the five TABLE species rotate hue like everyone else, because their
|
||||
-- slide is measured back out of their lookup table rather than falling
|
||||
-- back to a multiply that can only darken.
|
||||
--
|
||||
-- The COLORS pack is whatever PaletteFX defaults to in a headless process
|
||||
-- (the GBC pack). The RED++ pack is a different set of four colours per
|
||||
-- species and would want its own sheet.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local OUT = MOD .. "/.claude/shiny_update/palettes.html"
|
||||
|
||||
-- ------- the mod namespace, enough of it (see tests/shiny_test.lua)
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua
|
||||
-- reads; any mon carrying it is shiny as far as the engine is concerned.
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
-- ------- collect, in dex order
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function hex(c)
|
||||
return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0)
|
||||
end
|
||||
|
||||
local moved, still, missing = 0, 0, 0
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
row.normal = PaletteFX.monPal(Data, row.name)
|
||||
row.palName = PaletteFX.monPalName(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
row.shiny = PaletteFX.monPal(Data, row.name)
|
||||
row.shinyName = PaletteFX.monPalName(Data, row.name)
|
||||
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
row.kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex)
|
||||
or spec.slide)
|
||||
row.slide = slide
|
||||
|
||||
if not (row.normal and row.shiny) then
|
||||
missing = missing + 1
|
||||
else
|
||||
-- how far the two middle shades actually travelled, as the largest
|
||||
-- per-channel step: a row that reads 0 is a shiny nobody can see
|
||||
local d = 0
|
||||
for i = 2, #row.normal - 1 do
|
||||
local a, b = row.normal[i], row.shiny[i]
|
||||
if type(a) == "table" and type(b) == "table" then
|
||||
for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end
|
||||
end
|
||||
end
|
||||
row.delta = d
|
||||
if d >= 8 then moved = moved + 1 else still = still + 1 end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the page
|
||||
local out = {}
|
||||
local function w(s) out[#out + 1] = s end
|
||||
|
||||
w([[<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Shiny battle palettes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 62em; margin: 0 0 20px; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { padding: 3px 10px 3px 0; text-align: left; vertical-align: middle;
|
||||
white-space: nowrap; }
|
||||
th { color: #9aa0aa; font-weight: 400; border-bottom: 1px solid #2a2c34; }
|
||||
tr:hover { background: #1c1e26; }
|
||||
.sw { display: inline-block; width: 26px; height: 20px;
|
||||
border: 1px solid #000; vertical-align: middle; }
|
||||
.fixed { opacity: .45; }
|
||||
.kind-table { color: #ffc46b; }
|
||||
.kind-slide { color: #7fb2ff; }
|
||||
.flat { color: #ff8a8a; }
|
||||
td.n { text-align: right; color: #9aa0aa; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle palettes — normal beside shiny</h1>
|
||||
<p class="lede">What <code>ShinyPics</code> hands the battle pic cache, per
|
||||
species. The first and last shades are the shared paper and ink and are held
|
||||
still on purpose (shown faded); only the two middle shades are the Pokemon.
|
||||
<span class="kind-slide">slide</span> species use Stadium's declared values;
|
||||
the five <span class="kind-table">table</span> species use a slide measured
|
||||
back out of their own lookup table, which is what lets Gyarados reach red.
|
||||
Δ is the largest per-channel step across the two middle shades —
|
||||
a row in <span class="flat">red</span> barely moved.</p>
|
||||
]])
|
||||
|
||||
w(("<p class=\"lede\">%d species · %d visibly recoloured · "
|
||||
.. "%d barely moved · %d with no palette</p>\n")
|
||||
:format(#rows, moved, still, missing))
|
||||
|
||||
w("<table><tr><th>#</th><th>species</th><th>pal</th><th>kind</th>"
|
||||
.. "<th>slide h / s / l</th><th>normal</th><th>shiny</th><th>Δ</th>"
|
||||
.. "</tr>\n")
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local function swatches(cols)
|
||||
if not cols then return "—" end
|
||||
local o = {}
|
||||
for i, c in ipairs(cols) do
|
||||
local fixed = (i == 1 or i == #cols) and " fixed" or ""
|
||||
if type(c) == "table" and c[1] then
|
||||
o[#o + 1] = ("<span class=\"sw%s\" style=\"background:%s\" "
|
||||
.. "title=\"%d,%d,%d\"></span>")
|
||||
:format(fixed, hex(c), c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(o)
|
||||
end
|
||||
local s = row.slide
|
||||
w(("<tr><td class=\"n\">%03d</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"kind-%s\">%s</td><td>%s</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"n%s\">%s</td></tr>\n")
|
||||
:format(row.dex, row.name, tostring(row.palName), row.kind, row.kind,
|
||||
s and ("%.0f° / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
|
||||
s.l or 0) or "—",
|
||||
swatches(row.normal), swatches(row.shiny),
|
||||
(row.delta and row.delta < 8) and " flat" or "",
|
||||
row.delta and tostring(row.delta) or "—"))
|
||||
end
|
||||
|
||||
w("</table></body></html>\n")
|
||||
|
||||
local f = assert(io.open(OUT, "wb"))
|
||||
f:write(table.concat(out))
|
||||
f:close()
|
||||
|
||||
print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette")
|
||||
:format(OUT, #rows, moved, still, missing))
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv
|
||||
--
|
||||
-- Run from the PROJECT ROOT. One species per line, tab separated:
|
||||
--
|
||||
-- dex name spriteFront kind n1 n2 n3 n4 s1 s2 s3 s4
|
||||
--
|
||||
-- where each colour is r,g,b. TSV rather than JSON because there is no JSON
|
||||
-- encoder in this tree and the payload is eight colours and a path.
|
||||
--
|
||||
-- The COLOURS are the point: they come from the real wrap (ShinyPics over
|
||||
-- PaletteFX.monPal), not from a second implementation of it, so what the
|
||||
-- sheet shows is what the game bakes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151
|
||||
and def.spriteFront then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function cols(t)
|
||||
local o = {}
|
||||
for i = 1, 4 do
|
||||
local c = t and t[i]
|
||||
o[i] = (type(c) == "table" and c[1])
|
||||
and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0"
|
||||
end
|
||||
return table.concat(o, "\t")
|
||||
end
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local normal = PaletteFX.monPal(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
local shiny = PaletteFX.monPal(Data, row.name)
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
local kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
io.write(("%d\t%s\t%s\t%s\t%s\t%s\n")
|
||||
:format(row.dex, row.name, row.path, kind, cols(normal),
|
||||
cols(shiny)))
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every battle pic, baked normal beside baked shiny, as one HTML page.
|
||||
|
||||
luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv
|
||||
python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv
|
||||
|
||||
Run from the PROJECT ROOT. Writes
|
||||
mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained
|
||||
(every pic is a data: URI), so it can be opened or sent on its own.
|
||||
|
||||
------- the bake is the engine's, exactly
|
||||
|
||||
src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets
|
||||
its colour, and it does it ONCE at load with mapPixel:
|
||||
|
||||
col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4]
|
||||
|
||||
Four-shade DMG art, keyed on the RED channel alone, snapped to the species
|
||||
palette. That line is reproduced below rather than approximated, because the
|
||||
whole question this sheet answers is what the player will actually see -- an
|
||||
approximation of the bake would be answering a different one.
|
||||
|
||||
The colours come from tools/shiny_pic_dump.lua, which runs the real
|
||||
ShinyPics wrap over the real PaletteFX, so nothing here re-derives them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.getcwd()
|
||||
MOD = "mods/DramaticShapeVoxelMod"
|
||||
OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html")
|
||||
SCALE = 3 # nearest-neighbour, so the pixels stay pixels
|
||||
|
||||
|
||||
def parse_color(s):
|
||||
r, g, b = (int(v) for v in s.split(","))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def bake(img, pal):
|
||||
"""getImage's mapPixel: red channel picks the shade, alpha is kept."""
|
||||
img = img.convert("RGBA")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
f = r / 255.0
|
||||
if f > 0.83:
|
||||
c = pal[0]
|
||||
elif f > 0.5:
|
||||
c = pal[1]
|
||||
elif f > 0.17:
|
||||
c = pal[2]
|
||||
else:
|
||||
c = pal[3]
|
||||
px[x, y] = (c[0], c[1], c[2], a)
|
||||
return img
|
||||
|
||||
|
||||
def data_uri(img):
|
||||
img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
||||
stream = sys.stdin if src == "-" else open(src, encoding="utf-8")
|
||||
rows = []
|
||||
with stream:
|
||||
for line in stream:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
f = line.split("\t")
|
||||
rows.append({
|
||||
"dex": int(f[0]),
|
||||
"name": f[1],
|
||||
"path": f[2],
|
||||
"kind": f[3],
|
||||
"normal": [parse_color(c) for c in f[4:8]],
|
||||
"shiny": [parse_color(c) for c in f[8:12]],
|
||||
})
|
||||
|
||||
cards, missing = [], 0
|
||||
for row in rows:
|
||||
path = os.path.join(ROOT, row["path"])
|
||||
if not os.path.exists(path):
|
||||
missing += 1
|
||||
continue
|
||||
art = Image.open(path)
|
||||
n = data_uri(bake(art.copy(), row["normal"]))
|
||||
s = data_uri(bake(art.copy(), row["shiny"]))
|
||||
cards.append(
|
||||
'<figure class="k-{kind}">'
|
||||
'<div class="pair"><img src="{n}" alt="{name} normal">'
|
||||
'<img src="{s}" alt="{name} shiny"></div>'
|
||||
'<figcaption>{dex:03d} {name}<span>{kind}</span></figcaption>'
|
||||
"</figure>".format(kind=row["kind"], n=n, s=s,
|
||||
name=row["name"], dex=row["dex"])
|
||||
)
|
||||
|
||||
html = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Shiny battle pics</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 64em; margin: 0 0 20px; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
figure { margin: 0; background: #1c1e26; border: 1px solid #2a2c34;
|
||||
border-radius: 6px; padding: 8px; }
|
||||
.k-table { border-color: #7a5a1e; }
|
||||
.pair { display: flex; gap: 6px; background: #fff; border-radius: 3px;
|
||||
padding: 2px; }
|
||||
img { display: block; image-rendering: pixelated; }
|
||||
figcaption { margin-top: 6px; color: #9aa0aa; display: flex;
|
||||
justify-content: space-between; gap: 10px; }
|
||||
figcaption span { color: #6f757f; }
|
||||
.k-table figcaption span { color: #ffc46b; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle pics — normal on the left, shiny on the right</h1>
|
||||
<p class="lede">Each pair is the same four-shade art baked twice, through the
|
||||
palette the game itself would use: <code>getImage</code> keys on the red
|
||||
channel alone and snaps to the species palette, once, at load. The colours
|
||||
come from the live <code>ShinyPics</code> wrap, so this is what the flat
|
||||
battle screen draws — not a preview of it. Bordered cards are the five
|
||||
species Stadium gives a real alternate texture, whose slide is measured back
|
||||
out of that texture rather than declared.</p>
|
||||
<p class="lede">%d species%s</p>
|
||||
<div class="grid">%s</div>
|
||||
</body></html>
|
||||
""" % (len(cards),
|
||||
"" if not missing else " · %d with no art on disk" % missing,
|
||||
"\n".join(cards))
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
size = os.path.getsize(OUT) / 1024.0
|
||||
print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing,
|
||||
size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user