diff --git a/.claude/skills/pokebox-fragment-shader/SKILL.md b/.claude/skills/pokebox-fragment-shader/SKILL.md index 12489b6..ed49a17 100644 --- a/.claude/skills/pokebox-fragment-shader/SKILL.md +++ b/.claude/skills/pokebox-fragment-shader/SKILL.md @@ -12,44 +12,49 @@ When **adding an entirely new shader**, also update `src/types/index.ts` (add to | # | File | What to do | | --- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | 1 | `src/shaders/.frag` | Declare `uniform float uMyParam;` and use it in shader logic | -| 2 | `src/types/index.ts` | Add `MyParam: number` to `AppConfig` | -| 3 | `src/data/defaults.ts` | Add `MyParam: ,` with the hardcoded value being replaced | -| 4 | `src/three/buildCard.ts` | Add `['uMyParam', 'MyParam']` to the shader's entry in the uniform mapping object | -| 5 | `src/composables/useThreeScene.ts` | Add `[() => store.config.MyParam, 'uMyParam']` to the shader's `UniformMap` array | -| 6 | `src/components/ShaderControlsPanel.vue` | Add slider definition `{ label: 'My param', key: 'MyParam', min, max, step }` to the shader's section | +| 2 | `src/types/index.ts` | Add `myParam: number` to the shader's config interface (e.g. `FlatsilverReverseConfig`), and add to `ShaderConfigs` | +| 3 | `src/data/defaults.ts` | Add `myParam: ,` to the shader's defaults section | +| 4 | `src/data/shaderRegistry.ts` | Add `{ uniform: 'uMyParam', configPath: 'shaderKey.myParam' }` to the shader's registry entry | +| 5 | `src/three/buildCard.ts` | Import the shader frag and add to `FRAGMENT_SHADERS` map (only for new shaders) | +| 6 | `src/components/ShaderControlsPanel.vue` | Add slider definition to the shader's `sections` entry — **this is the UI; without it the user has no controls** | ## Naming conventions - **Shader uniform**: `u` prefix, PascalCase — `uGlareContrast` -- **Config key**: shader prefix + PascalCase — `masterBallGlareContrast`, `illustRareBarAngle`, `sirShineFrequency` +- **Config key**: camelCase property on the shader config interface — `glareContrast` +- **Registry configPath**: `shaderKey.propertyName` — `flatsilverReverse.rainbowScale` - **Slider label**: Short human-readable — `'Glare contrast'` ## Shader prefixes by type -| Shader file | Config prefix | -| -------------------------------- | ------------- | -| `illustration-rare.frag` | `illustRare` | -| `special-illustration-rare.frag` | `sir` | -| `ultra-rare.frag` | `ultraRare` | -| `rainbow-rare.frag` | `rainbowRare` | -| `master-ball.frag` | `masterBall` | -| `reverse-holo.frag` | `reverseHolo` | -| `shiny-rare.frag` | `shinyRare` | +| Shader file | shaderKey (in types/defaults/registry) | +| -------------------------------- | -------------------------------------- | +| `illustration-rare.frag` | `illustrationRare` | +| `special-illustration-rare.frag` | `specialIllustrationRare` | +| `tera-rainbow-rare.frag` | `teraRainbowRare` | +| `tera-shiny-rare.frag` | `teraShinyRare` | +| `ultra-rare.frag` | `ultraRare` | +| `rainbow-rare.frag` | `rainbowRare` | +| `reverse-holo.frag` | `reverseHolo` | +| `flatsilver-reverse.frag` | `flatsilverReverse` | +| `master-ball.frag` | `masterBall` | +| `shiny-rare.frag` | `shinyRare` | ## Slider definition format ```ts -{ label: 'My param', key: 'prefixMyParam', min: 0, max: 5, step: 0.05 } +{ label: 'My param', prop: 'myParam', min: 0, max: 5, step: 0.05 } // Optional suffix for display: suffix: '%' (multiplies by 100) or suffix: '°' // Use { subsection: 'Section Name' } to group sliders visually ``` ## Common pitfalls -1. **Forgetting `useThreeScene.ts`** — sliders will appear to work on load but won't update in real-time when dragged. `STYLE_UNIFORMS` in `buildCard.ts` only sets **initial** values; the `watchUniform` calls in `useThreeScene.ts` are what make sliders reactive at runtime. Both are required. -2. **Forgetting `buildCard.ts`** — uniform will be `undefined` at material creation, causing shader errors or zero values -3. **Using `blendScreen` for additive effects** — screen blend with near-zero values is a no-op; use `result +=` for additive highlights -4. **`uBackground` range is compressed** — values are mapped to ~0.37–0.63 (see `useThreeScene.ts` lines 272-273), so `abs(bg - 0.5)` maxes at ~0.13, not 0.5. Multiply to normalize if using for thresholds +1. **Forgetting `ShaderControlsPanel.vue`** — the shader will work but users will see "No controls available for this shader yet." instead of sliders. Every shader with configurable uniforms needs a `sections` entry in the Vue component. +2. **Forgetting `shaderRegistry.ts`** — the registry is the single source of truth. `buildCard.ts` reads initial values from it and `useUniformWatchers.ts` creates reactive watchers from it automatically. Without it, sliders won't update the shader in real-time. +3. **Forgetting `buildCard.ts`** — uniform will be `undefined` at material creation, causing shader errors or zero values +4. **Using `blendScreen` for additive effects** — screen blend with near-zero values is a no-op; use `result +=` for additive highlights +5. **`uBackground` range is compressed** — values are mapped to ~0.37–0.63 (see `useThreeScene.ts`), so `abs(bg - 0.5)` maxes at ~0.13, not 0.5. Multiply to normalize if using for thresholds ## Validation diff --git a/CLAUDE.md b/CLAUDE.md index 6006222..7b35173 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ Pokebox is a Vue 3 + Three.js app that creates a parallax "window into a box" ef - **Ultra Rare** (`ultra-rare.frag`): Metallic sparkle with fully parameterized brightness/contrast/bar controls - **Rainbow Rare** (`rainbow-rare.frag`): Metallic sparkle spotlight + iridescent glitter from iri-7 texture, for etched SV_ULTRA double rares - **Tera Rainbow Rare** (`tera-rainbow-rare.frag`): Rainbow holo overlay + metallic sparkle spotlight + dual etch sparkle layers, for Tera-tagged special illustration rares + - **Flatsilver Reverse** (`flatsilver-reverse.frag`): Inverted-mask flat silver rainbow sheen over card border/text areas (outside artwork window) with pointer-responsive spotlight, for FLAT_SILVER+REVERSE common/uncommon reverse holos - **Master Ball** (`master-ball.frag`): Etch foil composite on card base for RAINBOW+ETCHED masterball holo cards - Shared GLSL functions live in `src/shaders/common/` and are included via `#include` (resolved by `vite-plugin-glsl`): - `common/blend.glsl` — blend modes (overlay, screen, color-dodge, hard-light, etc.) @@ -56,6 +57,7 @@ Cards are assigned a `holoType` automatically by `mapHoloType()` in `cardCatalog | Rarity | Foil Type | Shader | |--------|-----------|--------| | any | `RAINBOW` + `ETCHED` mask | `master-ball` | +| any | `FLAT_SILVER` + `REVERSE` mask | `flatsilver-reverse` | | `SHINY_RARE` | any | `shiny-rare` | | `SHINY_ULTRA_RARE` | any | `ultra-rare` | | `SPECIAL_ILLUSTRATION_RARE` | `TERA` + `SHINY` tags | `tera-shiny-rare` | @@ -68,7 +70,8 @@ Cards are assigned a `holoType` automatically by `mapHoloType()` in `cardCatalog | `DOUBLE_RARE` | `SV_ULTRA` + `ETCHED` mask | `rainbow-rare` | | `DOUBLE_RARE` / `ILLUSTRATION_RARE` | other | `illustration-rare` | | `RARE` | `SV_HOLO` | `regular-holo` | -| `COMMON` / `UNCOMMON` / `RARE` (other) | any | `reverse-holo` | +| `COMMON` / `UNCOMMON` / `RARE` (other) | `FLAT_SILVER` + `REVERSE` mask | `flatsilver-reverse` | +| `COMMON` / `UNCOMMON` / `RARE` (other) | other | `reverse-holo` | ### Key modules @@ -211,6 +214,7 @@ The shader test suite (`src/shaders/__tests__/`) includes: 3. List required uniforms in compilation test 4. Run `bun test:shader` to verify 5. Add uniform↔config mappings to `SHADER_UNIFORM_REGISTRY` in `src/data/shaderRegistry.ts`, defaults to `src/data/defaults.ts`, types to `AppConfig` in `src/types/`, and the fragment shader import to `buildCard.ts` +6. Add a UI section to `ShaderControlsPanel.vue` with slider definitions for all configurable uniforms — without this, the panel shows "No controls available" when viewing cards with the new shader **When modifying shader uniforms**: diff --git a/src/components/ShaderControlsPanel.vue b/src/components/ShaderControlsPanel.vue index d3026ce..ac77f2d 100644 --- a/src/components/ShaderControlsPanel.vue +++ b/src/components/ShaderControlsPanel.vue @@ -464,6 +464,29 @@ const sections: ShaderSection[] = [ { label: 'Base saturation', prop: 'baseSaturation', min: 0, max: 3, step: 0.05 }, ], }, + { + id: 'flatsilver-reverse', + shaderKey: 'flatsilverReverse', + title: 'Flat Silver Reverse', + icon: '🪩', + items: [ + { subsection: 'Rainbow' }, + { label: 'Scale', prop: 'rainbowScale', min: 0.1, max: 5, step: 0.1 }, + { label: 'Tilt shift', prop: 'rainbowShift', min: 0, max: 5, step: 0.1 }, + { label: 'Saturation', prop: 'rainbowSaturation', min: 0, max: 2, step: 0.05 }, + { label: 'Opacity', prop: 'rainbowOpacity', min: 0, max: 1, step: 0.05, suffix: '%' }, + { subsection: 'Spotlight' }, + { label: 'Radius', prop: 'spotlightRadius', min: 0.1, max: 2, step: 0.05 }, + { label: 'Intensity', prop: 'spotlightIntensity', min: 0, max: 3, step: 0.05 }, + { subsection: 'Grain' }, + { label: 'Scale', prop: 'grainScale', min: 0.1, max: 2.0, step: 0.01 }, + { label: 'Intensity', prop: 'grainIntensity', min: 0, max: 1, step: 0.01 }, + { subsection: 'Overall' }, + { label: 'Brightness', prop: 'baseBrightness', min: 0.5, max: 2, step: 0.05 }, + { label: 'Contrast', prop: 'baseContrast', min: 0, max: 3, step: 0.05 }, + { label: 'Saturation', prop: 'baseSaturation', min: 0, max: 3, step: 0.05 }, + ], + }, ] function sliderProps(section: ShaderSection): string[] { diff --git a/src/composables/useCardLoader.ts b/src/composables/useCardLoader.ts index 1fdfcc5..f3e7501 100644 --- a/src/composables/useCardLoader.ts +++ b/src/composables/useCardLoader.ts @@ -39,6 +39,7 @@ export function useCardLoader(renderer: WebGLRenderer) { let birthdayTextures: BirthdayTextures | null = null let glitterTexture: Texture | null = null let noiseTexture: Texture | null = null + let grainTexture: Texture | null = null let cardBackTexture: Texture | null = null function clearCache(): void { @@ -110,11 +111,21 @@ export function useCardLoader(renderer: WebGLRenderer) { } } - tracedLoad(frontUrl, `load-texture card-front ${id}`, (tex) => { - applyFilters(tex, true) - cardTex = tex - onReady() - }) + tracedLoad( + frontUrl, + `load-texture card-front ${id}`, + (tex) => { + applyFilters(tex, true) + cardTex = tex + onReady() + }, + undefined, + () => { + console.warn(`[useCardLoader] Failed to load front texture for card: ${entry.label ?? id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? id}`) + onReady() + }, + ) if (hasMask) { tracedLoad( @@ -126,7 +137,11 @@ export function useCardLoader(renderer: WebGLRenderer) { onReady() }, undefined, - () => onReady(), // mask file missing — continue without it + () => { + console.warn(`[useCardLoader] Failed to load mask texture for card: ${entry.label ?? id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? id}`) + onReady() + }, ) } @@ -140,7 +155,11 @@ export function useCardLoader(renderer: WebGLRenderer) { onReady() }, undefined, - () => onReady(), // etch file missing — continue without it + () => { + console.warn(`[useCardLoader] Failed to load foil texture for card: ${entry.label ?? id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? id}`) + onReady() + }, ) } }) @@ -178,11 +197,23 @@ export function useCardLoader(renderer: WebGLRenderer) { } } - tracedLoad(entry.front, `load-texture hero-front ${entry.id}`, (tex) => { - applyFilters(tex, true) - cardTex = tex - onReady() - }) + const store = useAppStore() + + tracedLoad( + entry.front, + `load-texture hero-front ${entry.id}`, + (tex) => { + applyFilters(tex, true) + cardTex = tex + onReady() + }, + undefined, + () => { + console.warn(`[useCardLoader] Failed to load front texture for hero card: ${entry.label ?? entry.id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`) + onReady() + }, + ) if (hasMask) { tracedLoad( @@ -194,7 +225,11 @@ export function useCardLoader(renderer: WebGLRenderer) { onReady() }, undefined, - () => onReady(), + () => { + console.warn(`[useCardLoader] Failed to load mask texture for hero card: ${entry.label ?? entry.id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`) + onReady() + }, ) } @@ -208,7 +243,11 @@ export function useCardLoader(renderer: WebGLRenderer) { onReady() }, undefined, - () => onReady(), + () => { + console.warn(`[useCardLoader] Failed to load foil texture for hero card: ${entry.label ?? entry.id}`) + store.addToast(`Failed to load card asset: ${entry.label ?? entry.id}`) + onReady() + }, ) } }) @@ -403,6 +442,33 @@ export function useCardLoader(renderer: WebGLRenderer) { return noiseTexture } + function loadGrainTexture(): Promise { + if (grainTexture) return Promise.resolve(grainTexture) + + const store = useAppStore() + return new Promise((resolve) => { + loader.load( + 'img/grain.webp', + (tex) => { + applyFilters(tex) + tex.wrapS = tex.wrapT = 1000 // RepeatWrapping + grainTexture = tex + resolve(grainTexture) + }, + undefined, + () => { + console.warn('[useCardLoader] Failed to load texture: grain.webp') + store.addToast('Texture "grain.webp" could not be loaded') + resolve(null) + }, + ) + }) + } + + function getGrainTexture(): Texture | null { + return grainTexture + } + function loadCardBackTexture(): Promise { if (cardBackTexture) return Promise.resolve(cardBackTexture) @@ -446,6 +512,8 @@ export function useCardLoader(renderer: WebGLRenderer) { getGlitterTexture, loadNoiseTexture, getNoiseTexture, + loadGrainTexture, + getGrainTexture, loadCardBackTexture, getCardBackTexture, } diff --git a/src/composables/useThreeScene.ts b/src/composables/useThreeScene.ts index b32c0ef..35179fa 100644 --- a/src/composables/useThreeScene.ts +++ b/src/composables/useThreeScene.ts @@ -245,6 +245,9 @@ export function useThreeScene(containerRef: Ref) { // Load noise texture for master ball sparkle layer 2 cardLoader.loadNoiseTexture() + // Load grain texture for flatsilver-reverse foil surface + cardLoader.loadGrainTexture() + // Load card-back texture cardLoader.loadCardBackTexture() diff --git a/src/data/__tests__/cardCatalog.test.ts b/src/data/__tests__/cardCatalog.test.ts index ddc673f..a1a5d0f 100644 --- a/src/data/__tests__/cardCatalog.test.ts +++ b/src/data/__tests__/cardCatalog.test.ts @@ -33,9 +33,16 @@ describe('mapHoloType', () => { expect(mapHoloType('DOUBLE_RARE', 'SUN_PILLAR', 'HOLO')).toBe('double-rare') }) - it('returns illustration-rare for DOUBLE_RARE + non-SUN_PILLAR', () => { + it('returns illustration-rare for DOUBLE_RARE + non-SUN_PILLAR (non-FLAT_SILVER)', () => { expect(mapHoloType('DOUBLE_RARE', 'SV_HOLO', 'HOLO')).toBe('illustration-rare') - expect(mapHoloType('DOUBLE_RARE', 'FLAT_SILVER', 'REVERSE')).toBe('illustration-rare') + }) + + it('returns flatsilver-reverse for FLAT_SILVER + REVERSE (any rarity)', () => { + expect(mapHoloType('DOUBLE_RARE', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse') + expect(mapHoloType('RARE', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse') + expect(mapHoloType('COMMON', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse') + expect(mapHoloType('UNCOMMON', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse') + expect(mapHoloType('PROMO', 'FLAT_SILVER', 'REVERSE')).toBe('flatsilver-reverse') }) it('returns illustration-rare for ILLUSTRATION_RARE', () => { @@ -46,22 +53,17 @@ describe('mapHoloType', () => { expect(mapHoloType('RARE', 'SV_HOLO', 'HOLO')).toBe('regular-holo') }) - it('returns reverse-holo for RARE + non-SV_HOLO', () => { - expect(mapHoloType('RARE', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo') - }) - - it('returns reverse-holo for COMMON and UNCOMMON', () => { - expect(mapHoloType('COMMON', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo') - expect(mapHoloType('UNCOMMON', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo') + it('returns null for RARE + unmapped foil type', () => { + expect(mapHoloType('RARE', 'SUN_PILLAR', 'HOLO')).toBeNull() }) - it('returns reverse-holo for unknown designation', () => { - expect(mapHoloType('PROMO', 'FLAT_SILVER', 'REVERSE')).toBe('reverse-holo') + it('returns null for unmapped designation', () => { + expect(mapHoloType('PROMO', 'SV_HOLO', 'HOLO')).toBeNull() }) - it('RAINBOW without ETCHED mask follows designation, not master-ball', () => { - expect(mapHoloType('COMMON', 'RAINBOW', 'REVERSE')).toBe('reverse-holo') - expect(mapHoloType('RARE', 'RAINBOW', 'HOLO')).toBe('reverse-holo') + it('returns null for RAINBOW without ETCHED mask on unmapped rarity', () => { + expect(mapHoloType('COMMON', 'RAINBOW', 'REVERSE')).toBeNull() + expect(mapHoloType('RARE', 'RAINBOW', 'HOLO')).toBeNull() }) }) diff --git a/src/data/cardCatalog.ts b/src/data/cardCatalog.ts index 0e3eb39..4aa55a2 100644 --- a/src/data/cardCatalog.ts +++ b/src/data/cardCatalog.ts @@ -3,7 +3,7 @@ import type { CardCatalogEntry, HoloType, SetCardJson, SetDefinition } from '@/t import { assetUrl } from '@/utils/assetUrl' export const SET_REGISTRY: SetDefinition[] = [ - { id: 'me2-5_en', label: 'ASC Ascended Heros', jsonFile: 'me2-5_en/set.json' }, + { id: 'me2-5_en', label: 'ASC Ascended Heros', jsonFile: 'me2-5_en/set.json', preferReverse: true }, { id: 'sv3-5_en', label: 'MEW 151', jsonFile: 'sv3-5_en/sv3-5.en-US.json' }, { id: 'sv8-5_en', label: 'PRE Prismatic', jsonFile: 'sv8-5_en/sv8-5.en-US.json' }, { id: 'sv4-5_en', label: 'PAF Paldean Fates', jsonFile: 'sv4-5_en/sv-4-5.en-US.json' }, @@ -22,10 +22,13 @@ export function mapHoloType( foilType?: string, foilMask?: string, tags?: string[], -): HoloType { +): HoloType | null { // Master-ball holo: RAINBOW foil with ETCHED mask (common/uncommon masterball variants) if (foilType === 'RAINBOW' && foilMask === 'ETCHED') return 'master-ball' + // Flat silver reverse holo: FLAT_SILVER foil with REVERSE mask + if (foilType === 'FLAT_SILVER' && foilMask === 'REVERSE') return 'flatsilver-reverse' + switch (designation) { case 'SHINY_RARE': return 'shiny-rare' @@ -59,9 +62,9 @@ export function mapHoloType( return 'ultra-rare' case 'RARE': if (foilType === 'SV_HOLO') return 'regular-holo' - return 'reverse-holo' + return null default: - return 'reverse-holo' + return null } } @@ -81,10 +84,12 @@ export function extractPrefix(longFormID: string): string { /** * Pick the best foil entry for a card from its foil-only JSON entries. * Priority: RAINBOW > non-FLAT_SILVER > FLAT_SILVER. + * When preferReverse is true: RAINBOW > FLAT_SILVER+REVERSE > non-FLAT_SILVER > FLAT_SILVER. */ -export function pickBestFoilEntry(entries: SetCardJson[]): SetCardJson { +export function pickBestFoilEntry(entries: SetCardJson[], preferReverse = false): SetCardJson { return ( entries.find((e) => e.foil!.type === 'RAINBOW') || + (preferReverse && entries.find((e) => e.foil!.type === 'FLAT_SILVER' && e.foil!.mask === 'REVERSE')) || entries.find((e) => e.foil!.type !== 'FLAT_SILVER') || entries[0]! ) @@ -117,46 +122,58 @@ export async function loadSetCatalog(setId: string): Promise // Build catalog entries const sortedNumbers = [...byNumber.keys()].sort() - const entries = sortedNumbers.map((cardNum) => { - const group = byNumber.get(cardNum)! - const best = pickBestFoilEntry(group) - const name = best.name - const designation = best.rarity.designation - const foilType = best.foil!.type - const foilMask = best.foil!.mask - const isEtched = foilMask === 'ETCHED' - const holoType = mapHoloType(designation, foilType, foilMask, best.tags) - - // Get the file variant prefix from the JSON metadata, then map to actual files - const jsonPrefix = extractPrefix(best.ext.tcgl.longFormID) - // Map JSON prefix to file prefix (sph files were deleted, use mph instead) - const maskPrefix = jsonPrefix === 'sph' ? 'mph' : jsonPrefix - const label = `#${cardNum} ${name}` - - // Build texture paths (prefixed with asset base URL for CDN support) - const front = assetUrl(`${setId}/fronts/${cardNum}_front_2x.webp`) - const mask = assetUrl(`${setId}/holo-masks/${setId}_${cardNum}_${maskPrefix}.foil_up.webp`) - - // Etch foil (only for etched types) - const foil = isEtched - ? assetUrl(`${setId}/etch-foils/${setId}_${cardNum}_${maskPrefix}.etch_up.webp`) - : '' - - const entry: CardCatalogEntry = { id: cardNum, label, front, mask, foil, holoType } - - // Add iridescent textures for special-illustration-rare and ultra-rare cards - if ( - holoType === 'special-illustration-rare' || - holoType === 'ultra-rare' || - holoType === 'rainbow-rare' - ) { - entry.iri7 = 'img/151/iri-7.webp' - entry.iri8 = 'img/151/iri-8.webp' - entry.iri9 = 'img/151/iri-9.webp' - } - - return entry - }) + const entries = sortedNumbers + .map((cardNum) => { + const group = byNumber.get(cardNum)! + const best = pickBestFoilEntry(group, setDef.preferReverse) + const name = best.name + const designation = best.rarity.designation + const foilType = best.foil!.type + const foilMask = best.foil!.mask + const isEtched = foilMask === 'ETCHED' + const holoType = mapHoloType(designation, foilType, foilMask, best.tags) + + // Skip cards with unmapped foil/rarity combinations + if (!holoType) return null + + // Get the file variant prefix from the JSON metadata, then map to actual files. + // When preferReverse selected a FLAT_SILVER+REVERSE entry, asset files may only + // exist under the non-FLAT_SILVER sibling's prefix (temporary workaround until + // the asset pipeline generates per-variant masks). + let jsonPrefix = extractPrefix(best.ext.tcgl.longFormID) + if (setDef.preferReverse && foilType === 'FLAT_SILVER' && foilMask === 'REVERSE') { + const stdSibling = group.find((e) => e.foil!.type !== 'FLAT_SILVER') + if (stdSibling) jsonPrefix = extractPrefix(stdSibling.ext.tcgl.longFormID) + } + // Map JSON prefix to file prefix (sph files were deleted, use mph instead) + const maskPrefix = jsonPrefix === 'sph' ? 'mph' : jsonPrefix + const label = `#${cardNum} ${name}` + + // Build texture paths (prefixed with asset base URL for CDN support) + const front = assetUrl(`${setId}/fronts/${cardNum}_front_2x.webp`) + const mask = assetUrl(`${setId}/holo-masks/${setId}_${cardNum}_${maskPrefix}.foil_up.webp`) + + // Etch foil (only for etched types) + const foil = isEtched + ? assetUrl(`${setId}/etch-foils/${setId}_${cardNum}_${maskPrefix}.etch_up.webp`) + : '' + + const entry: CardCatalogEntry = { id: cardNum, label, front, mask, foil, holoType } + + // Add iridescent textures for special-illustration-rare and ultra-rare cards + if ( + holoType === 'special-illustration-rare' || + holoType === 'ultra-rare' || + holoType === 'rainbow-rare' + ) { + entry.iri7 = 'img/151/iri-7.webp' + entry.iri8 = 'img/151/iri-8.webp' + entry.iri9 = 'img/151/iri-9.webp' + } + + return entry + }) + .filter((entry): entry is CardCatalogEntry => entry !== null) return entries } diff --git a/src/data/defaults.ts b/src/data/defaults.ts index c204047..00779af 100644 --- a/src/data/defaults.ts +++ b/src/data/defaults.ts @@ -91,7 +91,7 @@ export const DEFAULT_CONFIG: AppConfig = { etchSparkle2Intensity: 0.95, etchSparkle2TiltSensitivity: 1.0, etchSparkle2TexMix: 1.0, - baseBrightness: 1.0, + baseBrightness: 0.95, baseContrast: 1.45, baseSaturation: 0.95, }, @@ -179,6 +179,19 @@ export const DEFAULT_CONFIG: AppConfig = { baseContrast: 1.7, baseSaturation: 1.0, }, + flatsilverReverse: { + rainbowScale: 0.6, + rainbowShift: 0.9, + rainbowSaturation: 0.5, + rainbowOpacity: 0.75, + spotlightRadius: 1.05, + spotlightIntensity: 2.55, + grainScale: 1.2, + grainIntensity: 1.0, + baseBrightness: 0.6, + baseContrast: 0.9, + baseSaturation: 3.0, + }, masterBall: { rainbowScale: 0.6, rainbowShift: 1.4, @@ -263,7 +276,7 @@ export const DEFAULT_CARD: CardTransform = { rotY: 0, } -export const STARTUP_CARD_ID = '170' +export const STARTUP_CARD_ID = '276' export const CARD_DEFAULTS = { x: 0, diff --git a/src/data/shaderRegistry.ts b/src/data/shaderRegistry.ts index 6722bbd..c377583 100644 --- a/src/data/shaderRegistry.ts +++ b/src/data/shaderRegistry.ts @@ -166,6 +166,19 @@ export const SHADER_UNIFORM_REGISTRY: Partial { testShaderCompilation('reverse-holo', reverseHoloFrag) + testShaderCompilation('flatsilver-reverse', flatsilverReverseFrag, [ + 'uRainbowScale', + 'uRainbowShift', + 'uRainbowSaturation', + 'uRainbowOpacity', + 'uSpotlightRadius', + 'uSpotlightIntensity', + 'uGrainScale', + 'uGrainIntensity', + 'uBaseBrightness', + 'uBaseContrast', + 'uBaseSaturation', + 'uHasFoil', + 'uHasGrain', + 'uGrainTex', + ]) + testShaderCompilation('tera-rainbow-rare', teraRainbowRareFrag, [ 'uHasFoil', 'uPointerFromCenter', diff --git a/src/shaders/__tests__/shader-validation.test.ts b/src/shaders/__tests__/shader-validation.test.ts index 1791a85..26086bc 100644 --- a/src/shaders/__tests__/shader-validation.test.ts +++ b/src/shaders/__tests__/shader-validation.test.ts @@ -6,6 +6,7 @@ import doubleRareFrag from '../double-rare.frag' import ultraRareFrag from '../ultra-rare.frag' import rainbowRareFrag from '../rainbow-rare.frag' import reverseHoloFrag from '../reverse-holo.frag' +import flatsilverReverseFrag from '../flatsilver-reverse.frag' import teraRainbowRareFrag from '../tera-rainbow-rare.frag' import masterBallFrag from '../master-ball.frag' import teraShinyRareFrag from '../tera-shiny-rare.frag' @@ -25,6 +26,7 @@ describe('Shader Static Validation', () => { 'ultra-rare': ultraRareFrag, 'rainbow-rare': rainbowRareFrag, 'reverse-holo': reverseHoloFrag, + 'flatsilver-reverse': flatsilverReverseFrag, 'tera-rainbow-rare': teraRainbowRareFrag, 'tera-shiny-rare': teraShinyRareFrag, 'master-ball': masterBallFrag, diff --git a/src/shaders/flatsilver-reverse.frag b/src/shaders/flatsilver-reverse.frag new file mode 100644 index 0000000..2b3d398 --- /dev/null +++ b/src/shaders/flatsilver-reverse.frag @@ -0,0 +1,150 @@ +precision highp float; + +uniform sampler2D uCardTex; +uniform sampler2D uCardBackTex; +uniform sampler2D uMaskTex; +uniform sampler2D uFoilTex; +uniform sampler2D uGrainTex; +uniform float uHasFoil; +uniform float uHasGrain; +uniform vec2 uPointer; +uniform vec2 uBackground; +uniform float uCardOpacity; +uniform float uTime; +uniform float uFade; + +// Flatsilver-reverse specific uniforms +uniform float uRainbowScale; +uniform float uRainbowShift; +uniform float uRainbowSaturation; +uniform float uRainbowOpacity; +uniform float uSpotlightRadius; +uniform float uSpotlightIntensity; +uniform float uGrainScale; +uniform float uGrainIntensity; +uniform float uBaseBrightness; +uniform float uBaseContrast; +uniform float uBaseSaturation; + +varying vec2 vUv; + +#include "common/blend.glsl" +#include "common/filters.glsl" +#include "common/rainbow.glsl" + +void main() { + vec2 uv = vUv; + + // ── Base card ──────────────────────────────────── + vec4 cardColor = texture2D(uCardTex, uv); + + // Back face: show card-back texture + if (!gl_FrontFacing) { + vec4 backColor = texture2D(uCardBackTex, uv); + gl_FragColor = vec4(backColor.rgb, backColor.a * uFade); + return; + } + + // ── Mask & foil textures ───────────────────────── + // Mask already marks the reverse holo area (border/text = bright, artwork = dark) + float mask = texture2D(uMaskTex, uv).r; + float foil = uHasFoil > 0.5 ? texture2D(uFoilTex, uv).r : 0.0; + + // Skip compositing if no effect area + float effectArea = max(mask, foil); + if (uCardOpacity < 0.01 || effectArea < 0.01) { + gl_FragColor = vec4(cardColor.rgb, cardColor.a * uFade); + return; + } + + // ── Pointer-driven spotlight ───────────────────── + vec2 toPointer = uPointer - uv; + float spotDist = length(toPointer); + float spotlight = 1.0 - smoothstep(0.0, uSpotlightRadius, spotDist); + spotlight = pow(spotlight, 1.5); + + // ── Rainbow gradient ───────────────────────────── + // Smooth single-band rainbow that shifts with tilt and pointer + float tiltOffset = (0.5 - uBackground.x) * uRainbowShift; + float ptrOffset = (0.5 - uPointer.x) * uRainbowShift * 0.5; + float rainbowT = uv.x * uRainbowScale + tiltOffset + ptrOffset + + (0.5 - uBackground.y) * uRainbowShift * 0.3; + vec3 rainbow = sunpillarGradient(rainbowT); + + // Desaturate to silvery tones + rainbow = adjustSaturate(rainbow, uRainbowSaturation); + rainbow = clamp(rainbow, 0.0, 1.0); + + // ── Grain texture with pseudo surface normals ──── + // Sample the grain texture (tiled) for surface roughness, + // then use dFdx/dFdy as pseudo surface normals so the rainbow + // follows the bumpy foil contours instead of appearing uniform. + float grain = 0.0; + float grainCatch = 0.0; + if (uHasGrain > 0.5) { + grain = texture2D(uGrainTex, uv * uGrainScale).r; + + // Surface gradient — approximates which direction each grain + // bump "faces". Where the foil curves, the gradient points + // along the slope so the rainbow band wraps around contours. + vec2 grainGrad = vec2(dFdx(grain), dFdy(grain)); + + // Tilt direction in UV space (centered at 0) + vec2 tiltDir = vec2(uBackground.x - 0.5, uBackground.y - 0.5); + + // Dot product: grain facets whose gradient aligns with tilt catch light + float catchAngle = dot(normalize(grainGrad + 0.001), normalize(tiltDir + 0.001)); + + // Gradient magnitude gates the effect — flat grain areas + // don't shimmer, only bumps with strong relief do + float gradStrength = clamp(length(grainGrad) * 80.0, 0.0, 1.0); + + grainCatch = (0.5 + 0.5 * catchAngle) * gradStrength; + } + + // ── Compose onto card ──────────────────────────── + vec3 result = cardColor.rgb; + float effectStrength = mask * uCardOpacity; + + // Silver toning: darken and desaturate in mask areas. + // The front texture has blown-out whites where foil covers on physical cards; + // base adjustments bring these down to a flat silver tone. + vec3 silverToned = adjustBrightness(result, uBaseBrightness); + silverToned = adjustContrast(silverToned, uBaseContrast); + silverToned = adjustSaturate(silverToned, uBaseSaturation); + result = mix(result, silverToned, effectStrength); + + // Apply grain texture — adds bumpy surface detail to the silver foil + if (uHasGrain > 0.5 && uGrainIntensity > 0.0) { + float grainHighlight = grain * uGrainIntensity * effectStrength; + result += grainHighlight; + } + + // Rainbow overlay — spotlight reveals more colour under the pointer, + // grain surface normals create per-texel shimmer variation + float revealAmount = uRainbowOpacity * (0.3 + 0.7 * spotlight * uSpotlightIntensity); + if (uHasGrain > 0.5 && foil > 0.5) { + revealAmount *= (0.5 + grainCatch); + } + vec3 overlaid = blendOverlay(result, rainbow); + result = mix(result, overlaid, effectStrength * revealAmount); + + // Foil emboss relief — dFdx/dFdy of foil as pseudo surface normals + // so embossed contour edges (lightning bolt, etc.) catch light with tilt + if (foil > 0.01) { + vec2 foilGrad = vec2(dFdx(foil), dFdy(foil)); + vec2 tiltDir = vec2(uBackground.x - 0.5, uBackground.y - 0.5); + float foilCatch = dot(normalize(foilGrad + 0.001), normalize(tiltDir + 0.001)); + float foilEdge = clamp(length(foilGrad) * 50.0, 0.0, 1.0); + + // Emboss edge highlights — contour edges facing the tilt glow + float embossLight = (0.5 + 0.5 * foilCatch) * foilEdge; + result += embossLight * foil * uCardOpacity * 1.2; + + // Embossed areas also get rainbow tint under spotlight + vec3 foilOverlay = blendOverlay(result, rainbow); + result = mix(result, foilOverlay, foil * uCardOpacity * spotlight * uSpotlightIntensity * 0.5); + } + + gl_FragColor = vec4(clamp(result, 0.0, 1.0), cardColor.a * uFade); +} diff --git a/src/three/CardSceneBuilder.ts b/src/three/CardSceneBuilder.ts index 1cdc4cc..4309fe7 100644 --- a/src/three/CardSceneBuilder.ts +++ b/src/three/CardSceneBuilder.ts @@ -73,6 +73,8 @@ export class CardSceneBuilder { effectiveShader === 'special-illustration-rare' ? loader.getSparkleIriTextures() : null const glitterTexture = loader.getGlitterTexture() const noiseTexture = loader.getNoiseTexture() + const grainTexture = + effectiveShader === 'flatsilver-reverse' ? loader.getGrainTexture() : null const cardBackTexture = loader.getCardBackTexture() return { iriTextures, @@ -80,6 +82,7 @@ export class CardSceneBuilder { sparkleIriTextures, glitterTexture, noiseTexture, + grainTexture, cardBackTexture, } } @@ -116,6 +119,7 @@ export class CardSceneBuilder { sparkleIriTextures, glitterTexture, noiseTexture, + grainTexture, cardBackTexture, } = this.resolveExtraTextures(loader, effectiveShader) const compositeMesh = buildCardMesh( @@ -134,6 +138,7 @@ export class CardSceneBuilder { noiseTexture, cardBackTexture, sparkleIriTextures, + grainTexture, ) compositeMesh.geometry.dispose() compositeMesh.geometry = new PlaneGeometry(cardW, cardH) @@ -218,6 +223,7 @@ export class CardSceneBuilder { sparkleIriTextures, glitterTexture, noiseTexture, + grainTexture, cardBackTexture, } = this.resolveExtraTextures(loader, effectiveShader) const tempMesh = buildCardMesh( @@ -233,6 +239,7 @@ export class CardSceneBuilder { noiseTexture, cardBackTexture, sparkleIriTextures, + grainTexture, ) // Swap material only — keep mesh position/rotation/scale intact diff --git a/src/three/CarouselLayoutBuilder.ts b/src/three/CarouselLayoutBuilder.ts index 7f3f942..16abe5e 100644 --- a/src/three/CarouselLayoutBuilder.ts +++ b/src/three/CarouselLayoutBuilder.ts @@ -79,6 +79,7 @@ export function buildCarouselLayout( sparkleIriTextures: ReturnType glitterTexture: ReturnType noiseTexture: ReturnType + grainTexture: ReturnType cardBackTexture: ReturnType }, getEffectiveShaderForHero: (compoundId: string) => ShaderStyle, @@ -106,6 +107,7 @@ export function buildCarouselLayout( sparkleIriTextures, glitterTexture, noiseTexture, + grainTexture, cardBackTexture, } = resolveExtraTextures(loader, effectiveShader) @@ -122,6 +124,7 @@ export function buildCarouselLayout( noiseTexture, cardBackTexture, sparkleIriTextures, + grainTexture, ) // Replace geometry with a thin box so cards have visible thickness on the ring diff --git a/src/three/StackLayoutBuilder.ts b/src/three/StackLayoutBuilder.ts index 7050b51..233d984 100644 --- a/src/three/StackLayoutBuilder.ts +++ b/src/three/StackLayoutBuilder.ts @@ -25,6 +25,7 @@ export function buildStackLayout( sparkleIriTextures: { iri1: Texture; iri2: Texture } | null glitterTexture: Texture | null noiseTexture: Texture | null + grainTexture: Texture | null cardBackTexture: Texture | null }, ): Mesh[] { @@ -76,6 +77,7 @@ export function buildStackLayout( extras.noiseTexture, extras.cardBackTexture, extras.sparkleIriTextures, + extras.grainTexture, ) // Replace geometry with our sized one shaderMesh.geometry.dispose() diff --git a/src/three/buildCard.ts b/src/three/buildCard.ts index 354b6a7..811155b 100644 --- a/src/three/buildCard.ts +++ b/src/three/buildCard.ts @@ -23,6 +23,7 @@ import doubleRareFrag from '@/shaders/double-rare.frag' import ultraRareFrag from '@/shaders/ultra-rare.frag' import rainbowRareFrag from '@/shaders/rainbow-rare.frag' import reverseHoloFrag from '@/shaders/reverse-holo.frag' +import flatsilverReverseFrag from '@/shaders/flatsilver-reverse.frag' import teraRainbowRareFrag from '@/shaders/tera-rainbow-rare.frag' import masterBallFrag from '@/shaders/master-ball.frag' import teraShinyRareFrag from '@/shaders/tera-shiny-rare.frag' @@ -47,6 +48,7 @@ const FRAGMENT_SHADERS: Record = { 'ultra-rare': ultraRareFrag, 'rainbow-rare': rainbowRareFrag, 'reverse-holo': reverseHoloFrag, + 'flatsilver-reverse': flatsilverReverseFrag, 'tera-rainbow-rare': teraRainbowRareFrag, 'tera-shiny-rare': teraShinyRareFrag, 'master-ball': masterBallFrag, @@ -106,6 +108,7 @@ export function buildCardMesh( noiseTexture?: Texture | null, cardBackTexture?: Texture | null, sparkleIriTextures?: { iri1: Texture; iri2: Texture } | null, + grainTexture?: Texture | null, ): Mesh { const cardH = dims.screenH * config.cardSize const cardW = cardH * CARD_ASPECT @@ -154,6 +157,11 @@ export function buildCardMesh( uniforms.uBirthdayDank2Tex = { value: birthdayTextures?.dank2 ?? blackPixel } } + if (shaderStyle === 'flatsilver-reverse') { + uniforms.uGrainTex = { value: grainTexture || blackPixel } + uniforms.uHasGrain = { value: grainTexture ? 1.0 : 0.0 } + } + const mesh = new Mesh( cardGeo, new ShaderMaterial({ diff --git a/src/types/index.ts b/src/types/index.ts index ada51ba..9b3489a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -160,6 +160,20 @@ export interface ReverseHoloConfig { baseSaturation: number } +export interface FlatsilverReverseConfig { + rainbowScale: number + rainbowShift: number + rainbowSaturation: number + rainbowOpacity: number + spotlightRadius: number + spotlightIntensity: number + grainScale: number + grainIntensity: number + baseBrightness: number + baseContrast: number + baseSaturation: number +} + export interface MasterBallConfig { rainbowScale: number rainbowShift: number @@ -252,6 +266,7 @@ export interface ShaderConfigs { ultraRare: UltraRareConfig rainbowRare: RainbowRareConfig reverseHolo: ReverseHoloConfig + flatsilverReverse: FlatsilverReverseConfig masterBall: MasterBallConfig shinyRare: ShinyRareConfig } @@ -299,6 +314,7 @@ export type ShaderStyle = | 'ultra-rare' | 'rainbow-rare' | 'reverse-holo' + | 'flatsilver-reverse' | 'master-ball' | 'shiny-rare' export type HoloType = @@ -311,6 +327,7 @@ export type HoloType = | 'ultra-rare' | 'rainbow-rare' | 'reverse-holo' + | 'flatsilver-reverse' | 'master-ball' | 'shiny-rare' @@ -325,6 +342,8 @@ export interface SetDefinition { id: string label: string jsonFile: string + /** When true, prefer FLAT_SILVER+REVERSE over SV_HOLO for cards with both variants. */ + preferReverse?: boolean } export interface SetCardJson {