#version 450

layout(binding = 0, std140) uniform UniformBufferObject {
    mat4  mvp;
    float screenWidth;
    float screenHeight;
    float skyR;
    float skyG;
    int   playerX;
    int   playerY;
    int   playerZ;
    float skyB;
    // Planet label
    int   planetVisible;
    int   planetScreenX;
    int   planetScreenY;
    int   planetBiome;
    int   planetDist;
    int   planetCX;
    int   planetCY;
    int   planetCZ;
    int   planetRadius;
    int   planetName0;   // glyph codes chars 0-3  (8 bits each)
    int   planetName1;   // glyph codes chars 4-7
    int   planetName2;   // glyph codes chars 8-11
    int   planetScreenR;     // visual disc radius in screen pixels + gap
    int   planetOriginDist;  // distance from world origin to planet centre
    // UI / game state
    int   gameState;    // 0=mainmenu, 1=playing, 2=paused, 3=quitting
    int   hotbarSlot;   // 0-3
    int   invOpen;      // 0/1
    // Inventory: 16 slots
    int   inv0; int inv1; int inv2; int inv3;
    int   inv4; int inv5; int inv6; int inv7;
    int   inv8; int inv9; int inv10; int inv11;
    int   inv12; int inv13; int inv14; int inv15;
    // Menu
    int   menuSubState; // 0=world list, 1=create form, 2=world settings
    int   menuSel;
    int   worldCount;
    // World names (8 × 3 ints = 24 ints)
    int   wn00; int wn01; int wn02;
    int   wn10; int wn11; int wn12;
    int   wn20; int wn21; int wn22;
    int   wn30; int wn31; int wn32;
    int   wn40; int wn41; int wn42;
    int   wn50; int wn51; int wn52;
    int   wn60; int wn61; int wn62;
    int   wn70; int wn71; int wn72;
    // Nickname
    int   nick0; int nick1; int nick2;
    // Text input (name 6 ints, seed 4 ints)
    int   tb0; int tb1; int tb2; int tb3; int tb4; int tb5;
    int   textLen;
    int   textField;   // 0=name, 1=seed
    int   sb0; int sb1; int sb2; int sb3;
    int   seedLen;
    int   cursorBlink;
    int   heldType;    // BlockType of held item (0 = none)
    int   heldCount;   // count of held item
    int   cursorX;     // cursor X in framebuffer pixels
    int   cursorY;     // cursor Y in framebuffer pixels
    int   underWater;  // 1 if camera eye is inside a water block
    int   airPercent;  // 0-100 remaining air
    float camWX;       // offset 428 — float camera world position
    float camWY;       // offset 432
    float camWZ;       // offset 436
    // Dynamic lighting (skip camWPad/Pad2/mobMvp via explicit offset)
    layout(offset = 512) float sunDirX;
    layout(offset = 516) float sunDirY;
    layout(offset = 520) float sunDirZ;
    layout(offset = 524) float timeOfDay;        // 0=midnight 0.5=noon
    layout(offset = 528) float gameTime;         // seconds, wraps at 3600
    layout(offset = 532) int   worldPlayTimeSec; // total seconds in this world
    layout(offset = 536) float worldPlayTimeFrac;// fractional second (0-1)
    layout(offset = 540) float camVX;            // camera velocity (blocks/sec)
    layout(offset = 544) float camVY;
    layout(offset = 548) float camVZ;
    layout(offset = 552) float spaceBlend;       // 0=surface, 1=space
    layout(offset = 556) int   settingsWorldSel; // world index for settings screen (-1=none)
    layout(offset = 560) int   deleteConfirm;    // 1=showing delete confirmation
    layout(offset = 564) int   playerHp;         // current player HP (0-16)
    layout(offset = 568) float breakProgress;    // 0=not breaking, 1=fully broken
    layout(offset = 572) int   craft0;           // craft grid slot 0
    layout(offset = 576) int   craft1;           // craft grid slot 1
    layout(offset = 580) int   craft2;           // craft grid slot 2
    layout(offset = 584) int   craft3;           // craft grid slot 3
    layout(offset = 588) int   craft4;           // craft grid slot 4
    layout(offset = 592) int   craft5;           // craft grid slot 5
    layout(offset = 596) int   craft6;           // craft grid slot 6
    layout(offset = 600) int   craft7;           // craft grid slot 7
    layout(offset = 604) int   craft8;           // craft grid slot 8
    layout(offset = 608) int   craftOut;         // craft output slot
    layout(offset = 612) int   recipeAvail;      // bitmask: bit i = recipe i available
} ubo;

layout(binding = 1) uniform sampler2D atlasSampler;

layout(location = 0) in vec3 fragColor;
layout(location = 1) in vec2 fragUV;
layout(location = 2) in float fragTexIndex;
layout(location = 3) in float fragFogDist;

layout(location = 0) out vec4 outColor;

const float ATLAS_COLUMNS   = 8.0;
const float ATLAS_ROWS      = 9.0;
const float FACE_SIZE       = 16.0;
const float WATER_TILE_BEGIN = 60.0;
const float WATER_TILE_END   = 65.0;

bool isWaterTile(float tile) {
    return tile >= WATER_TILE_BEGIN && tile <= WATER_TILE_END;
}

// ---------------------------------------------------------------------------
// 4×5 bitmap font  (column-major, bit3 = leftmost pixel per nibble)
// Codes:  0-9 digits | 10 '-' | 11 X | 12 Y | 13 Z | 14 ':'
//         15-37 letters A-W   | 38 ',' | 39 '|' cursor | 40 '►' marker
//         100+ space/unknown
// ---------------------------------------------------------------------------
uint digitGlyph(int c) {
    if (c ==  0) return uint(0x69996); // 0
    if (c ==  1) return uint(0x4C44E); // 1
    if (c ==  2) return uint(0x6168F); // 2
    if (c ==  3) return uint(0xE161E); // 3
    if (c ==  4) return uint(0x99F11); // 4
    if (c ==  5) return uint(0xF8E1E); // 5
    if (c ==  6) return uint(0x68E96); // 6
    if (c ==  7) return uint(0xF1222); // 7
    if (c ==  8) return uint(0x69696); // 8
    if (c ==  9) return uint(0x69716); // 9
    if (c == 10) return uint(0x00600); // -
    if (c == 11) return uint(0x99699); // X
    if (c == 12) return uint(0x99622); // Y
    if (c == 13) return uint(0xF124F); // Z
    if (c == 14) return uint(0x06060); // :
    if (c == 15) return uint(0x69F99); // A
    if (c == 16) return uint(0xE9E9E); // B
    if (c == 17) return uint(0x68886); // C
    if (c == 18) return uint(0xE999E); // D
    if (c == 19) return uint(0xF8E8F); // E
    if (c == 20) return uint(0xF8E88); // F
    if (c == 21) return uint(0x68B96); // G
    if (c == 22) return uint(0x99F99); // H
    if (c == 23) return uint(0xE444E); // I
    if (c == 24) return uint(0x31196); // J
    if (c == 25) return uint(0x9ACA9); // K
    if (c == 26) return uint(0x8888F); // L
    if (c == 27) return uint(0x9FF99); // M
    if (c == 28) return uint(0x9DB99); // N
    if (c == 29) return uint(0x69996); // O
    if (c == 30) return uint(0xE9E88); // P
    if (c == 31) return uint(0x699A5); // Q
    if (c == 32) return uint(0xE9EA9); // R
    if (c == 33) return uint(0x68616); // S
    if (c == 34) return uint(0xF4444); // T
    if (c == 35) return uint(0x99996); // U
    if (c == 36) return uint(0x99966); // V
    if (c == 37) return uint(0x999F6); // W
    if (c == 38) return uint(0x00048); // ,
    if (c == 39) return uint(0x44444); // | cursor bar
    if (c == 40) return uint(0x13731); // ► selected item marker
    return uint(0);                    // space / unknown
}

bool glyphPixel(uint glyph, int px, int py) {
    uint row = (glyph >> uint((4 - py) * 4)) & 15u;
    return (row & (8u >> uint(px))) != 0u;
}

// ---------------------------------------------------------------------------
// Dynamic number formatter
// ---------------------------------------------------------------------------
int numDigits(int a) {
    if (a >= 1000000000) return 10;
    if (a >= 100000000)  return 9;
    if (a >= 10000000)   return 8;
    if (a >= 1000000)    return 7;
    if (a >= 100000)     return 6;
    if (a >= 10000)      return 5;
    if (a >= 1000)       return 4;
    if (a >= 100)        return 3;
    if (a >= 10)         return 2;
    return 1;
}
int pow10(int e) {
    if (e == 9) return 1000000000;
    if (e == 8) return 100000000;
    if (e == 7) return 10000000;
    if (e == 6) return 1000000;
    if (e == 5) return 100000;
    if (e == 4) return 10000;
    if (e == 3) return 1000;
    if (e == 2) return 100;
    if (e == 1) return 10;
    return 1;
}
int valWidth(int val) {
    int a = val < 0 ? -val : val;
    int n = numDigits(a);
    return n + (n - 1) / 3 + (val < 0 ? 1 : 0);
}
int valChar(int val, int col) {
    bool neg = val < 0;
    int a = neg ? -val : val;
    if (neg) { if (col == 0) return 10; col--; }
    int n = numDigits(a);
    int firstGroup = ((n - 1) % 3) + 1;
    int digitIdx;
    if (col < firstGroup) {
        digitIdx = col;
    } else {
        int pos = col - firstGroup;
        int groupPos = pos % 4;
        if (groupPos == 0) return 38;
        int groupNum = pos / 4;
        digitIdx = firstGroup + groupNum * 3 + (groupPos - 1);
    }
    return (a / pow10(n - 1 - digitIdx)) % 10;
}

// ---------------------------------------------------------------------------
// Player HUD
// ---------------------------------------------------------------------------
int hudCharCode(int col) {
    int wx = valWidth(ubo.playerX);
    int wy = valWidth(ubo.playerY);
    if (col == 0) return 11;
    if (col == 1) return 14;
    if (col == 2) return 100;
    if (col < 3 + wx) return valChar(ubo.playerX, col - 3);
    int off = 3 + wx;
    if (col < off + 2) return 100;
    off += 2;
    if (col == off)   return 12;
    if (col == off+1) return 14;
    if (col == off+2) return 100;
    if (col < off + 3 + wy) return valChar(ubo.playerY, col - (off + 3));
    off += 3 + wy;
    if (col < off + 2) return 100;
    off += 2;
    if (col == off)   return 13;
    if (col == off+1) return 14;
    if (col == off+2) return 100;
    return valChar(ubo.playerZ, col - (off + 3));
}

// ---------------------------------------------------------------------------
// Planet label helpers
// ---------------------------------------------------------------------------
int planetNameChar(int col) {
    int word = (col < 4) ? ubo.planetName0 : (col < 8) ? ubo.planetName1 : ubo.planetName2;
    int pos  = col & 3;
    return (word >> (24 - 8 * pos)) & 0xFF;
}
int planetCharCode(int row, int col) {
    if (row == 0) return (col < 12) ? planetNameChar(col) : 100;
    if (row >= 1 && row <= 3) {
        int v     = (row==1) ? ubo.planetCX : (row==2) ? ubo.planetCY : ubo.planetCZ;
        int label = (row==1) ? 11 : (row==2) ? 12 : 13;
        if (col == 0) return label;
        if (col == 1) return 14;
        int c = col - 2;
        if (c >= 0 && c < valWidth(v)) return valChar(v, c);
        return 100;
    }
    if (row == 4) {
        if (col == 0) return 32; if (col == 1) return 14;
        int c = col - 2;
        if (c >= 0 && c < valWidth(ubo.planetRadius)) return valChar(ubo.planetRadius, c);
        return 100;
    }
    if (row == 5) {
        if (col == 0) return 18; if (col == 1) return 14;
        int c = col - 2;
        if (c >= 0 && c < valWidth(ubo.planetDist)) return valChar(ubo.planetDist, c);
        return 100;
    }
    if (row == 6) {
        if (col == 0) return 29; if (col == 1) return 14;
        int c = col - 2;
        if (c >= 0 && c < valWidth(ubo.planetOriginDist)) return valChar(ubo.planetOriginDist, c);
        return 100;
    }
    return 100;
}

// ---------------------------------------------------------------------------
// World-name unpacking helpers  (8 worlds × 3 ints each)
// ---------------------------------------------------------------------------
int getWorldWord(int worldIdx, int wordPos) {
    if (worldIdx == 0) { if (wordPos==0) return ubo.wn00; if (wordPos==1) return ubo.wn01; return ubo.wn02; }
    if (worldIdx == 1) { if (wordPos==0) return ubo.wn10; if (wordPos==1) return ubo.wn11; return ubo.wn12; }
    if (worldIdx == 2) { if (wordPos==0) return ubo.wn20; if (wordPos==1) return ubo.wn21; return ubo.wn22; }
    if (worldIdx == 3) { if (wordPos==0) return ubo.wn30; if (wordPos==1) return ubo.wn31; return ubo.wn32; }
    if (worldIdx == 4) { if (wordPos==0) return ubo.wn40; if (wordPos==1) return ubo.wn41; return ubo.wn42; }
    if (worldIdx == 5) { if (wordPos==0) return ubo.wn50; if (wordPos==1) return ubo.wn51; return ubo.wn52; }
    if (worldIdx == 6) { if (wordPos==0) return ubo.wn60; if (wordPos==1) return ubo.wn61; return ubo.wn62; }
    if (worldIdx == 7) { if (wordPos==0) return ubo.wn70; if (wordPos==1) return ubo.wn71; return ubo.wn72; }
    return (100<<24)|(100<<16)|(100<<8)|100;
}
int worldNameChar(int worldIdx, int charPos) {
    int word   = getWorldWord(worldIdx, charPos / 4);
    int bitOff = 24 - 8 * (charPos & 3);
    return (word >> bitOff) & 0xFF;
}

// Nickname char (12 chars = 3 ints)
int nickChar(int col) {
    int word = (col < 4) ? ubo.nick0 : (col < 8) ? ubo.nick1 : ubo.nick2;
    int pos  = col & 3;
    return (word >> (24 - 8 * pos)) & 0xFF;
}

// ---------------------------------------------------------------------------
// Odometer (rolling digit) animation helpers
// ---------------------------------------------------------------------------
// Count digit chars after colInNum (ignoring commas) = decimal position of this digit
int digitDecPos(int val, int colInNum) {
    int n = valWidth(val);
    int after = 0;
    for (int j = colInNum + 1; j < n && j < 12; j++) {
        if (valChar(val, j) != 38) after++;   // 38 = comma char code
    }
    return after;
}

// Scroll a digit given [0,1) progress: 0=digit sits still, 1=fully flipped to next.
// progress is only non-zero when the caller decides animation is warranted.
bool scrollDigit(int cur, int nxt, float progress, int gx, int gy) {
    int shift  = int(progress * 5.0);
    int sampGy = gy + shift;
    int digit  = (sampGy >= 5) ? nxt : cur;
    return gx < 4 && glyphPixel(digitGlyph(digit), gx, sampGy % 5);
}

// Velocity-gated coordinate odometer:
//   absFloatVal — actual float world position (abs)
//   absVel      — abs velocity of that axis in blocks/sec
//   decPos      — decimal position of the digit (0=units, 1=tens, …)
// Digit is shown clean when the player is standing still; animates during movement.
bool odometerPixel(float absFloatVal, float absVel, int decPos, int gx, int gy) {
    float scaled = absFloatVal / float(pow10(decPos));
    float frac   = fract(scaled);
    int   cur    = int(scaled) % 10;
    int   nxt    = (cur + 1) % 10;

    // Speed of THIS digit level (digit-changes per second at this decimal position)
    float digitSpd = absVel / float(pow10(decPos));

    // Gate: below 0.3 digit-changes/sec treat as stationary → clean render
    float progress = 0.0;
    if (digitSpd > 0.3) {
        // Animate only the final 20% before the flip; ease in with smoothstep
        float t = max(0.0, (frac - 0.80) / 0.20);
        progress = t * t * (3.0 - 2.0 * t);  // smoothstep
    }
    return scrollDigit(cur, nxt, progress, gx, gy);
}

// Odometer pixel for one char slot within a formatted integer number
bool numOdometerPixel(int val, float absFloatVal, float absVel, int colInNum, int gx, int gy) {
    int code = valChar(val, colInNum);
    if (code == 38) return gx < 4 && glyphPixel(digitGlyph(38), gx, gy); // comma — static
    if (code == 10) return gx < 4 && glyphPixel(digitGlyph(10), gx, gy); // '-'  — static
    return odometerPixel(absFloatVal, absVel, digitDecPos(val, colInNum), gx, gy);
}

// Timer odometer: col 0-7 → H H : M M : S S
// Time always advances so we use a tight threshold (last 8%) instead of velocity gating.
bool timerOdometerPixel(int col, int gx, int gy) {
    int   tot  = max(0, ubo.worldPlayTimeSec);
    int   hh   = tot / 3600;
    int   mm   = (tot % 3600) / 60;
    int   ss   = tot % 60;
    float wpt  = float(tot) + ubo.worldPlayTimeFrac;
    if (col == 2 || col == 5)
        return gx < 4 && glyphPixel(digitGlyph(14), gx, gy);  // ':' static
    float frac = 0.0; int cur = 0; int nxt = 0;
    if      (col == 0) { cur = hh/10;  nxt = (cur+1)%10; frac = fract(wpt/36000.0); }
    else if (col == 1) { cur = hh%10;  nxt = (cur+1)%10; frac = fract(wpt/ 3600.0); }
    else if (col == 3) { cur = mm/10;  nxt = (cur+1)% 6; frac = fract(wpt/  600.0); }
    else if (col == 4) { cur = mm%10;  nxt = (cur+1)%10; frac = fract(wpt/   60.0); }
    else if (col == 6) { cur = ss/10;  nxt = (cur+1)% 6; frac = fract(wpt/   10.0); }
    else               { cur = ss%10;  nxt = (cur+1)%10; frac = ubo.worldPlayTimeFrac; }
    // Animate only in the last 8% before flip → digit is clean 92% of the time
    float t        = max(0.0, (frac - 0.92) / 0.08);
    float progress = t * t * (3.0 - 2.0 * t);
    return scrollDigit(cur, nxt, progress, gx, gy);
}

// Text-input buffer char (24 chars = 6 ints)
int textBufChar(int col) {
    int wi = col / 4;
    int bitOff = 24 - 8 * (col & 3);
    int word = 0;
    if (wi == 0) word = ubo.tb0;
    else if (wi == 1) word = ubo.tb1;
    else if (wi == 2) word = ubo.tb2;
    else if (wi == 3) word = ubo.tb3;
    else if (wi == 4) word = ubo.tb4;
    else              word = ubo.tb5;
    return (word >> bitOff) & 0xFF;
}

// Seed buffer char (16 chars = 4 ints)
int seedBufChar(int col) {
    int wi = col / 4;
    int bitOff = 24 - 8 * (col & 3);
    int word = 0;
    if (wi == 0) word = ubo.sb0;
    else if (wi == 1) word = ubo.sb1;
    else if (wi == 2) word = ubo.sb2;
    else              word = ubo.sb3;
    return (word >> bitOff) & 0xFF;
}

// ---------------------------------------------------------------------------
// Inventory helpers
// ---------------------------------------------------------------------------
int invSlotType(int slot) {
    if (slot== 0) return (ubo.inv0  >> 16) & 0xFFFF;
    if (slot== 1) return (ubo.inv1  >> 16) & 0xFFFF;
    if (slot== 2) return (ubo.inv2  >> 16) & 0xFFFF;
    if (slot== 3) return (ubo.inv3  >> 16) & 0xFFFF;
    if (slot== 4) return (ubo.inv4  >> 16) & 0xFFFF;
    if (slot== 5) return (ubo.inv5  >> 16) & 0xFFFF;
    if (slot== 6) return (ubo.inv6  >> 16) & 0xFFFF;
    if (slot== 7) return (ubo.inv7  >> 16) & 0xFFFF;
    if (slot== 8) return (ubo.inv8  >> 16) & 0xFFFF;
    if (slot== 9) return (ubo.inv9  >> 16) & 0xFFFF;
    if (slot==10) return (ubo.inv10 >> 16) & 0xFFFF;
    if (slot==11) return (ubo.inv11 >> 16) & 0xFFFF;
    if (slot==12) return (ubo.inv12 >> 16) & 0xFFFF;
    if (slot==13) return (ubo.inv13 >> 16) & 0xFFFF;
    if (slot==14) return (ubo.inv14 >> 16) & 0xFFFF;
    return (ubo.inv15 >> 16) & 0xFFFF;
}
int invSlotCount(int slot) {
    if (slot== 0) return ubo.inv0  & 0xFFFF;
    if (slot== 1) return ubo.inv1  & 0xFFFF;
    if (slot== 2) return ubo.inv2  & 0xFFFF;
    if (slot== 3) return ubo.inv3  & 0xFFFF;
    if (slot== 4) return ubo.inv4  & 0xFFFF;
    if (slot== 5) return ubo.inv5  & 0xFFFF;
    if (slot== 6) return ubo.inv6  & 0xFFFF;
    if (slot== 7) return ubo.inv7  & 0xFFFF;
    if (slot== 8) return ubo.inv8  & 0xFFFF;
    if (slot== 9) return ubo.inv9  & 0xFFFF;
    if (slot==10) return ubo.inv10 & 0xFFFF;
    if (slot==11) return ubo.inv11 & 0xFFFF;
    if (slot==12) return ubo.inv12 & 0xFFFF;
    if (slot==13) return ubo.inv13 & 0xFFFF;
    if (slot==14) return ubo.inv14 & 0xFFFF;
    return ubo.inv15 & 0xFFFF;
}

// Count total of blockType across all 16 inventory slots
int countInv(int btype) {
    int n = 0;
    for (int i = 0; i < 16; i++) {
        int packed = 0;
        if (i== 0) packed = ubo.inv0;
        else if (i== 1) packed = ubo.inv1;
        else if (i== 2) packed = ubo.inv2;
        else if (i== 3) packed = ubo.inv3;
        else if (i== 4) packed = ubo.inv4;
        else if (i== 5) packed = ubo.inv5;
        else if (i== 6) packed = ubo.inv6;
        else if (i== 7) packed = ubo.inv7;
        else if (i== 8) packed = ubo.inv8;
        else if (i== 9) packed = ubo.inv9;
        else if (i==10) packed = ubo.inv10;
        else if (i==11) packed = ubo.inv11;
        else if (i==12) packed = ubo.inv12;
        else if (i==13) packed = ubo.inv13;
        else if (i==14) packed = ubo.inv14;
        else             packed = ubo.inv15;
        if (((packed >> 16) & 0xFFFF) == btype) n += (packed & 0xFFFF);
    }
    return n;
}

// Sample the texture atlas for a block's top face icon, or item icon.
// blockType: 0=Air,1=Grass,...,11=Water, 12+=items
vec4 sampleBlockIcon(int blockType, vec2 iconUV) {
    if (blockType == 0) return vec4(0.0); // Air = empty slot
    float tile;
    if (blockType <= 11) {
        tile = float((blockType - 1) * 6); // blocks: use top face (face index 0)
    } else {
        tile = float(66 + (blockType - 12)); // items: 1 tile each starting at 66
    }
    float tileX = mod(tile, ATLAS_COLUMNS);
    float tileY = floor(tile / ATLAS_COLUMNS);
    vec2 atlasMin   = vec2(tileX / ATLAS_COLUMNS, tileY / ATLAS_ROWS);
    vec2 atlasMax   = vec2((tileX+1.0) / ATLAS_COLUMNS, (tileY+1.0) / ATLAS_ROWS);
    vec2 atlasTexel = vec2(1.0/(ATLAS_COLUMNS*FACE_SIZE), 1.0/(ATLAS_ROWS*FACE_SIZE));
    vec2 sampleUV   = mix(atlasMin + 0.5*atlasTexel, atlasMax - 0.5*atlasTexel, clamp(iconUV,0.0,1.0));
    return texture(atlasSampler, sampleUV);
}

// Draw a single glyph character at (cx,cy) top-left in screen coords, scale SC.
// Returns true if this pixel is a lit glyph pixel.
bool drawChar(int code, int sc, int cx, int cy) {
    int px = int(gl_FragCoord.x) - cx;
    int py = int(gl_FragCoord.y) - cy;
    if (px < 0 || px >= 4*sc || py < 0 || py >= 5*sc) return false;
    return glyphPixel(digitGlyph(code), px/sc, py/sc);
}

// Draw a string (glyph codes from charFn) and return true if pixel is lit.
// maxCols chars wide, each CHAR_W pixels, starting at (x0,y0).
// charFn(col) must return a glyph code.
// Returns whether the pixel at gl_FragCoord is inside the text area AND lit.
bool drawString12(int sc, int x0, int y0,
                  int c0,  int c1,  int c2,  int c3,
                  int c4,  int c5,  int c6,  int c7,
                  int c8,  int c9,  int c10, int c11) {
    int CW = 4*sc+sc; // char width with 1-pixel gap
    int px = int(gl_FragCoord.x) - x0;
    int py = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc) return false;
    if (px < 0 || px >= CW*12) return false;
    int col  = px / CW;
    int gx   = (px % CW) / sc;
    int gy   = py / sc;
    if (gx >= 4) return false; // gap pixel
    int codes[12];
    codes[0]=c0; codes[1]=c1; codes[2]=c2;  codes[3]=c3;
    codes[4]=c4; codes[5]=c5; codes[6]=c6;  codes[7]=c7;
    codes[8]=c8; codes[9]=c9; codes[10]=c10; codes[11]=c11;
    if (col < 0 || col >= 12) return false;
    return glyphPixel(digitGlyph(codes[col]), gx, gy);
}

// Simpler: draw N chars from world name (world index wi, starting char offset 0)
bool inWorldNameStr(int wi, int sc, int x0, int y0) {
    int CW  = 4*sc + sc;
    int px  = int(gl_FragCoord.x) - x0;
    int py  = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc || px < 0 || px >= CW*12) return false;
    int col = px / CW;
    int gx  = (px % CW) / sc;
    int gy  = py / sc;
    if (gx >= 4) return false;
    return glyphPixel(digitGlyph(worldNameChar(wi, col)), gx, gy);
}

bool inNickStr(int sc, int x0, int y0) {
    int CW  = 4*sc + sc;
    int px  = int(gl_FragCoord.x) - x0;
    int py  = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc || px < 0 || px >= CW*12) return false;
    int col = px / CW;
    int gx  = (px % CW) / sc;
    int gy  = py / sc;
    if (gx >= 4) return false;
    return glyphPixel(digitGlyph(nickChar(col)), gx, gy);
}

// Draw text input content (24 chars max) + blinking cursor
bool inTextInputStr(int sc, int x0, int y0, int len, bool isActive) {
    int CW  = 4*sc + sc;
    int px  = int(gl_FragCoord.x) - x0;
    int py  = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc) return false;
    // Content chars
    if (px >= 0 && px < CW*min(len,24)) {
        int col = px / CW;
        int gx  = (px % CW) / sc;
        int gy  = py / sc;
        if (gx < 4) {
            int code = textBufChar(col);
            if (glyphPixel(digitGlyph(code), gx, gy)) return true;
        }
    }
    // Cursor after content
    if (isActive && ubo.cursorBlink == 1) {
        int cx = x0 + CW*min(len,23);
        int cpx = int(gl_FragCoord.x) - cx;
        if (cpx >= 0 && cpx < CW) {
            int gx = cpx / sc;
            int gy = py / sc;
            if (gx < 4 && glyphPixel(digitGlyph(39), gx, gy)) return true;
        }
    }
    return false;
}

bool inSeedInputStr(int sc, int x0, int y0, int len, bool isActive) {
    int CW  = 4*sc + sc;
    int px  = int(gl_FragCoord.x) - x0;
    int py  = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc) return false;
    if (px >= 0 && px < CW*min(len,16)) {
        int col = px / CW;
        int gx  = (px % CW) / sc;
        int gy  = py / sc;
        if (gx < 4) {
            int code = seedBufChar(col);
            if (glyphPixel(digitGlyph(code), gx, gy)) return true;
        }
    }
    if (isActive && ubo.cursorBlink == 1) {
        int cx = x0 + CW*min(len,15);
        int cpx = int(gl_FragCoord.x) - cx;
        if (cpx >= 0 && cpx < CW) {
            int gx = cpx / sc;
            int gy = py / sc;
            if (gx < 4 && glyphPixel(digitGlyph(39), gx, gy)) return true;
        }
    }
    return false;
}

// Draw a fixed ASCII word (up to 8 chars) at (x0,y0) with scale sc.
// Pass glyph codes directly.
bool inWord(int sc, int x0, int y0, int n,
            int c0, int c1, int c2, int c3,
            int c4, int c5, int c6, int c7) {
    int CW = 4*sc + sc;
    int px = int(gl_FragCoord.x) - x0;
    int py = int(gl_FragCoord.y) - y0;
    if (py < 0 || py >= 5*sc || px < 0 || px >= CW*n) return false;
    int col = px / CW;
    int gx  = (px % CW) / sc;
    int gy  = py / sc;
    if (gx >= 4) return false;
    int codes[8]; codes[0]=c0;codes[1]=c1;codes[2]=c2;codes[3]=c3;
                  codes[4]=c4;codes[5]=c5;codes[6]=c6;codes[7]=c7;
    if (col >= n) return false;
    return glyphPixel(digitGlyph(codes[col]), gx, gy);
}

// ---------------------------------------------------------------------------
// Inventory / hotbar rendering helpers
// ---------------------------------------------------------------------------
// SLOT_SZ is computed dynamically in main() based on sh; passed as parameter.

bool renderSlot(int slotIdx, int sx, int sy, bool selected, int SLOT_SZ, int SLOT_BRD) {
    int btype = invSlotType(slotIdx);
    int cnt   = invSlotCount(slotIdx);

    // Border colour: white if selected, else dark
    vec3 borderCol = selected ? vec3(1.0) : vec3(0.3);
    float borderW  = selected ? float(SLOT_BRD) : float(SLOT_BRD - 1);

    if (float(sx) < borderW || float(sx) >= float(SLOT_SZ) - borderW ||
        float(sy) < borderW || float(sy) >= float(SLOT_SZ) - borderW) {
        outColor = vec4(borderCol, 1.0);
        return true;
    }

    // Dark background
    outColor = vec4(0.12, 0.12, 0.12, 0.90);

    // Block icon
    int iconW = SLOT_SZ - 2*SLOT_BRD;
    if (sx >= SLOT_BRD && sx < SLOT_SZ - SLOT_BRD &&
        sy >= SLOT_BRD && sy < SLOT_SZ - SLOT_BRD && btype > 0) {
        vec2 iconUV = vec2(float(sx - SLOT_BRD) / float(iconW),
                           float(sy - SLOT_BRD) / float(iconW));
        vec4 tex = sampleBlockIcon(btype, iconUV);
        if (tex.a > 0.05) {
            float shade = 0.80 + 0.20 * iconUV.y;
            outColor = vec4(tex.rgb * shade, 1.0);
        }
    }

    // Count label (bottom-right); scale with slot size
    if (cnt > 1) {
        int sc = max(1, SLOT_SZ / 24);
        int CW = 4*sc + sc;
        int labelX = SLOT_SZ - 2 - CW*numDigits(cnt);
        int labelY = SLOT_SZ - 2 - 5*sc;
        int px = sx - labelX;
        int py = sy - labelY;
        if (px >= 0 && px < CW*numDigits(cnt) && py >= 0 && py < 5*sc) {
            int col = px / CW;
            int gx  = (px % CW) / sc;
            int gy  = py / sc;
            if (gx < 4) {
                int nd = numDigits(cnt);
                int digit = (cnt / pow10(nd - 1 - col)) % 10;
                if (glyphPixel(digitGlyph(digit), gx, gy))
                    outColor = vec4(1.0, 1.0, 0.4, 1.0);
            }
        }
    }
    return true;
}

// ---------------------------------------------------------------------------
// ACES filmic tone-mapping (Hill 2014 approximation)
vec3 aces(vec3 x) {
    return clamp((x * (2.51*x + 0.03)) / (x * (2.43*x + 0.59) + 0.14), 0.0, 1.0);
}

// Saturation tweak (0=greyscale, 1=original, >1=vivid)
vec3 saturate(vec3 c, float s) {
    float lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
    return mix(vec3(lum), c, s);
}

// Day/night global lighting — returns (multiplier, sunColour)
// elev: sunDirY (-1=midnight, +1=noon)
vec3 sunLightColour(float elev) {
    float dayF  = clamp(elev * 2.5 + 0.3, 0.0, 1.0);
    float dawnF = pow(max(0.0, 1.0 - abs(elev * 4.0)), 2.5);
    vec3 nightCol = vec3(0.10, 0.14, 0.28); // moonlight blue
    vec3 dawnCol  = vec3(1.00, 0.52, 0.12); // golden-orange
    vec3 dayCol   = vec3(1.05, 1.00, 0.92); // warm white
    return mix(mix(nightCol, dawnCol, dawnF), dayCol, dayF * (1.0 - dawnF * 0.5));
}

float sunIntensity(float elev) {
    return clamp(elev * 3.0 + 0.15, 0.0, 1.0);
}

// ---------------------------------------------------------------------------
// Value noise helpers for procedural cloud shapes
float cloudHash(vec2 p) {
    p = fract(p * vec2(127.1, 311.7));
    p += dot(p, p + 17.5);
    return fract(p.x * p.y);
}
float cloudNoise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    f = f * f * (3.0 - 2.0 * f); // smoothstep
    return mix(mix(cloudHash(i),           cloudHash(i + vec2(1,0)), f.x),
               mix(cloudHash(i+vec2(0,1)), cloudHash(i + vec2(1,1)), f.x), f.y);
}
float cloudFBM(vec2 p) {
    return cloudNoise(p) * 0.500
         + cloudNoise(p * 2.1 + vec2(1.3, 4.7)) * 0.250
         + cloudNoise(p * 4.3 + vec2(2.7, 1.9)) * 0.125
         + cloudNoise(p * 8.7 + vec2(5.1, 3.3)) * 0.0625;
}

// ---------------------------------------------------------------------------
void main() {
    const vec3  skyColor  = vec3(ubo.skyR, ubo.skyG, ubo.skyB);
    const float sw = ubo.screenWidth;
    const float sh = ubo.screenHeight;

    // ── Break overlay (texIndex == -8.0) ─────────────────────────────────
    if (abs(fragTexIndex + 8.0) < 0.1) {
        float p = clamp(ubo.breakProgress, 0.0, 1.0);
        if (p <= 0.0) discard;
        // No loops or trig — deterministic UV-grid cracks, safe on all drivers.
        vec2  cell  = fract(fragUV * 3.0);
        float ex    = min(cell.x, 1.0 - cell.x);
        float ey    = min(cell.y, 1.0 - cell.y);
        float d1    = abs(cell.x - cell.y);          // one diagonal
        float d2    = abs(cell.x + cell.y - 1.0);    // other diagonal
        float w     = mix(0.09, 0.022, p);           // lines thicken as progress drops
        if (min(min(ex, ey), min(d1, d2)) >= w) discard;
        outColor = vec4(0.0, 0.0, 0.0, 0.70);
        return;
    }

    // ── Mob solid geometry (texIndex == -6.0) ────────────────────────────
    if (fragTexIndex < -5.5) {
        float elev  = ubo.sunDirY;
        float dayF  = clamp(elev * 2.5 + 0.3, 0.0, 1.0);
        float ambnt = mix(0.07, 0.24, clamp(elev * 3.0 + 0.5, 0.0, 1.0));
        float face  = fragUV.x; // face brightness packed by CPU (1.0/0.75/0.70/0.60/0.55/0.40)
        float light = ambnt + (1.0 - ambnt) * dayF * face;
        vec3  lit   = fragColor * light;
        // ACES tonemap for consistency with world
        lit = clamp((lit*(2.51*lit+0.03))/(lit*(2.43*lit+0.59)+0.14), 0.0, 1.0);
        outColor = vec4(lit, 1.0);
        return;
    }

    // ── Sun disc + corona / Moon (texIndex == -5.0) ──────────────────────
    if (fragTexIndex < -4.5) {
        vec2  uv = fragUV - vec2(0.5);
        float r  = length(uv) * 2.0;

        float disc   = smoothstep(1.0, 0.78, r);
        float corona = exp(-max(0.0, r - 0.82) * 6.5) * 0.60;
        float bright = disc + corona;
        if (bright < 0.01) discard;

        // fragColor carries the tint set by CPU (sun warm / moon silver / corona orange)
        vec3 col = mix(fragColor, vec3(1.0, 0.97, 0.88), smoothstep(0.0, 0.7, disc));
        float vis = 1.0 - ubo.spaceBlend;
        outColor = vec4(col, clamp(bright, 0.0, 1.0) * vis);
        return;
    }


    // ── Cloud layer (texIndex == -4.0) ───────────────────────────────────
    if (fragTexIndex < -3.5) {
        float band    = fragColor.z;  // 0=low cumulus, 0.5=mid cumulus, 1.0=high cirrus
        vec2  seed    = fragColor.xy * 31.7;
        vec2  centered = fragUV - 0.5;

        // Radial envelope — gives each cloud its own isolated puff shape.
        // Cirrus: compress Y envelope → wide oval; cumulus: near-round.
        float cirrusY  = mix(1.0, 3.5, band);
        float r        = length(vec2(centered.x, centered.y * cirrusY));
        float envelope = 1.0 - smoothstep(0.38, 0.52, r);
        vec2  edgeD    = min(fragUV, 1.0 - fragUV);
        float edgeF    = smoothstep(0.0, 0.05, edgeD.x) * smoothstep(0.0, 0.05, edgeD.y);
        envelope *= edgeF;

        // FBM density — cirrus UV compressed in Z (V) so ridges run left-right (X dir).
        // stretchY scales fragUV.y → noise varies faster in Z → elongated features in X.
        float stretchY = mix(1.0, 3.8, band);
        vec2  uv1 = vec2(fragUV.x, fragUV.y * stretchY) * mix(3.0, 1.8, band) + seed;
        vec2  uv2 = vec2(fragUV.x, fragUV.y * stretchY) * mix(5.5, 3.2, band)
                  + seed * 1.5 + vec2(11.3, 7.8);
        float d1      = cloudFBM(uv1);
        float d2      = cloudFBM(uv2) * mix(0.45, 0.20, band);
        float density = clamp(d1 + d2, 0.0, 1.0);

        // Edge sharpness: cumulus has crisp edges, cirrus is soft and wispy
        float thresh    = mix(0.52, 0.28, band);
        float spread    = mix(0.18, 0.58, band);
        float noiseMask = smoothstep(thresh, thresh + spread, density);

        float alpha = envelope * noiseMask;

        // Per-band peak opacity: low cumulus 0.88, mid 0.72, high cirrus 0.35
        float maxA = mix(0.88,
                         mix(0.72, 0.35, clamp((band - 0.5) * 2.0, 0.0, 1.0)),
                         clamp(band * 2.0, 0.0, 1.0));
        alpha *= maxA * (1.0 - ubo.spaceBlend);
        if (alpha < 0.01) discard;

        // ── Lighting ────────────────────────────────────────────────────
        float elev  = ubo.sunDirY;
        float dayF  = clamp(elev * 2.5 + 0.3, 0.0, 1.0);
        float dawnF = pow(max(0.0, 1.0 - abs(elev * 3.5)), 2.5);

        // Colour by altitude: cirrus is uniformly bright (no under-shadow),
        // cumulus has a darker underside blending to white top
        vec3 dayTop   = vec3(0.97, 0.98, 1.00);
        vec3 dayShad  = vec3(0.60, 0.64, 0.74);
        vec3 dayBase  = mix(dayShad, dayTop, 0.30 + band * 0.70);

        vec3 dawnTop  = vec3(1.00, 0.78, 0.48);
        vec3 dawnShad = vec3(0.52, 0.30, 0.30);
        vec3 dawnBase = mix(dawnShad, dawnTop, 0.20 + band * 0.60);

        vec3 nightTop  = vec3(0.10, 0.13, 0.24);
        vec3 nightShad = vec3(0.04, 0.05, 0.12);
        vec3 nightBase = mix(nightShad, nightTop, 0.30 + band * 0.40);

        vec3 cloudColor = mix(nightBase, mix(dawnBase, dayBase, dayF), max(dayF, dawnF));

        // Sunrise/sunset rim glow on cumulus edges only
        float rimGlow = (1.0 - smoothstep(0.45, 0.65, density)) * dawnF
                      * (1.0 - band) * 0.75;
        cloudColor += rimGlow * vec3(1.00, 0.55, 0.18);

        // Self-shadow: denser cumulus interiors darken slightly (no effect on cirrus)
        cloudColor *= 1.0 - (1.0 - band) * density * 0.28;

        outColor = vec4(cloudColor, alpha);
        return;
    }

    if (fragTexIndex < -1.5) {

        // ==================================================================
        // MAIN MENU (gameState == 0)
        // ==================================================================
        if (ubo.gameState == 0) {
            // Dark overlay background
            outColor = vec4(0.04, 0.05, 0.12, 0.96);

            // Scale UI text with resolution: SC=3 at 720p, 6 at 1440p, 9 at 2160p
            int SC  = max(3, int(sh) / 240);
            int CW  = 4*SC + SC;
            int CH  = 5*SC;
            int cx = int(sw * 0.5);
            int cy = int(sh * 0.5);

            // ── Title "MINECRAFT" ──
            // M=27,I=23,N=28,E=19,C=17,R=32,A=15,F=20,T=34
            int TSC = SC + SC / 2;  // ~1.5× body scale
            int TCW = 4*TSC + TSC;
            int tx0 = cx - TCW*9/2;
            int ty0 = int(sh*0.12);
            int tpx = int(gl_FragCoord.x) - tx0;
            int tpy = int(gl_FragCoord.y) - ty0;
            if (tpy >= 0 && tpy < 5*TSC && tpx >= 0 && tpx < TCW*9) {
                int col = tpx / TCW; int gx = (tpx%TCW)/TSC; int gy = tpy/TSC;
                if (gx < 4) {
                    int titleCodes[9]; titleCodes[0]=27;titleCodes[1]=23;titleCodes[2]=28;
                    titleCodes[3]=19;titleCodes[4]=17;titleCodes[5]=32;
                    titleCodes[6]=15;titleCodes[7]=20;titleCodes[8]=34;
                    if (glyphPixel(digitGlyph(titleCodes[col]), gx, gy)) {
                        outColor = vec4(0.95, 0.75, 0.15, 1.0); return;
                    }
                }
            }

            // ── Nickname: "PLAYER: <nick>" ──
            int ny = int(sh*0.23);
            // "PLAYER" = P(30) L(26) A(15) Y(12) E(19) R(32) :(14) space(100)
            if (inWord(SC, cx-CW*9, ny, 8, 30,26,15,12,19,32,14,100)) {
                outColor = vec4(0.85, 0.85, 0.85, 1.0); return;
            }
            if (inNickStr(SC, cx-CW*9+CW*8, ny)) {
                outColor = vec4(1.0, 0.9, 0.4, 1.0); return;
            }

            if (ubo.menuSubState == 0) {
                // ── World list ──────────────────────────────────────────────
                int listTop = int(sh*0.33);

                // "WORLDS" header: W(37) O(29) R(32) L(26) D(18) S(33)
                if (inWord(SC, cx-CW*3, listTop-CH-8, 6, 37,29,32,26,18,33,100,100)) {
                    outColor = vec4(0.75, 0.95, 0.75, 1.0); return;
                }

                // Separator line
                {
                    int lx = int(gl_FragCoord.x);
                    int ly = int(gl_FragCoord.y) - (listTop - 4);
                    int x0 = cx - 170; int x1 = cx + 170;
                    if (lx >= x0 && lx < x1 && ly == 0) {
                        outColor = vec4(0.5, 0.5, 0.5, 0.7); return;
                    }
                }

                // World entries — sizes scale with SC
                int entryH = CH + 8;         // entry row height
                int entryS = CH + 16;        // entry row spacing (entryH + gap)
                int listW  = CW * 20;        // total list width
                int listX0 = cx - listW/2;
                for (int w = 0; w < ubo.worldCount && w < 8; ++w) {
                    int ey  = listTop + w * entryS;
                    int bx  = int(gl_FragCoord.x) - listX0;
                    int by  = int(gl_FragCoord.y) - ey;
                    bool sel = (w == ubo.menuSel);
                    if (bx >= 0 && bx < listW && by >= 0 && by < entryH) {
                        // Settings button: rightmost CW*3 pixels of entry
                        int settBtnStart = listW - CW*3;
                        if (bx >= settBtnStart) {
                            outColor = vec4(0.10, 0.15, 0.32, 0.90);
                            if (bx==settBtnStart || by==0 || by==entryH-1)
                                outColor = vec4(0.35, 0.45, 0.70, 1.0);
                            // ► glyph centered in button area
                            int sgx_off = bx - settBtnStart - (CW*3 - CW)/2;
                            int sgy_off = by - (entryH - CH)/2;
                            if (sgx_off >= 0 && sgx_off < CW && sgy_off >= 0 && sgy_off < CH) {
                                int gx = sgx_off/SC; int gy = sgy_off/SC;
                                if (gx < 4 && glyphPixel(digitGlyph(40), gx, gy))
                                    outColor = vec4(0.7, 0.85, 1.0, 1.0);
                            }
                            return;
                        }
                        outColor = sel ? vec4(0.20, 0.30, 0.45, 0.85) : vec4(0.10, 0.12, 0.18, 0.75);
                        // Selection marker ►
                        if (sel) {
                            int mpx = bx - 4;
                            int mpy = by - (entryH - CH)/2;
                            if (mpx >= 0 && mpx < CW && mpy >= 0 && mpy < CH) {
                                int gx = mpx/SC; int gy = mpy/SC;
                                if (gx<4 && glyphPixel(digitGlyph(40), gx, gy)) {
                                    outColor = vec4(1.0,0.9,0.2,1.0); return;
                                }
                            }
                        }
                        // World name text
                        int nmx = bx - (sel ? CW*2 : CW);
                        int nmy = by - (entryH - CH)/2;
                        if (nmx >= 0 && nmy >= 0 && nmy < CH) {
                            int col = nmx / CW;
                            int gx  = (nmx % CW) / SC;
                            int gy  = nmy / SC;
                            if (col < 12 && gx < 4 && gy < 5) {
                                if (glyphPixel(digitGlyph(worldNameChar(w, col)), gx, gy)) {
                                    outColor = sel ? vec4(1.0,1.0,1.0,1.0) : vec4(0.8,0.8,0.8,1.0);
                                    return;
                                }
                            }
                        }
                        return;
                    }
                }

                // No worlds yet — "NO WORLDS" hint
                if (ubo.worldCount == 0) {
                    // N(28) O(29) space W(37) O(29) R(32) L(26) D(18)
                    if (inWord(SC, cx-CW*4, listTop, 8, 28,29,100,37,29,32,26,18)) {
                        outColor = vec4(0.5,0.5,0.5,1.0); return;
                    }
                }

                // "CREATE WORLD" button
                // C(17)R(32)E(19)A(15)T(34)E(19) space W(37)O(29)R(32)L(26)D(18)
                int btnY = int(sh*0.78);
                int btnX = cx - CW*6;
                int btnW = CW*12; int btnH = CH+10;
                int bbx = int(gl_FragCoord.x) - (btnX-8);
                int bby = int(gl_FragCoord.y) - (btnY-4);
                if (bbx >= 0 && bbx < btnW+16 && bby >= 0 && bby < btnH) {
                    outColor = vec4(0.15,0.30,0.15,0.90);
                    if (bbx==0||bbx==btnW+15||bby==0||bby==btnH-1)
                        outColor = vec4(0.4,0.8,0.4,1.0);
                    if (inWord(SC,btnX,btnY, 6, 17,32,19,15,34,19,100,100)) {
                        outColor = vec4(0.9,1.0,0.9,1.0); return;
                    }
                    if (inWord(SC,btnX+CW*7,btnY, 5, 37,29,32,26,18,100,100,100)) {
                        outColor = vec4(0.9,1.0,0.9,1.0); return;
                    }
                    return;
                }

            } else if (ubo.menuSubState == 1) {
                // ── Create-world form ────────────────────────────────────────
                int fy = int(sh*0.35);
                // "CREATE WORLD" title
                if (inWord(SC,cx-CW*6,fy-CH-16, 6, 17,32,19,15,34,19,100,100)) {
                    outColor=vec4(0.9,1.0,0.9,1.0); return;
                }
                if (inWord(SC,cx-CW*6+CW*7,fy-CH-16, 5, 37,29,32,26,18,100,100,100)) {
                    outColor=vec4(0.9,1.0,0.9,1.0); return;
                }

                // Field width scales with resolution: fit ~22 chars comfortably
                int fw   = min(CW*22 + 8, int(sw*7/10));
                int ffx0 = cx - fw/2;
                // Name field label "NAME:"  N(28)A(15)M(27)E(19):(14)
                if (inWord(SC,ffx0,fy, 5, 28,15,27,19,14,100,100,100)) {
                    outColor=vec4(0.75,0.85,1.0,1.0); return;
                }
                // Name field box
                {
                    bool nameFocused = (ubo.textField == 0);
                    int fy0=fy+CH+4; int fh=CH+8;
                    int bx=int(gl_FragCoord.x)-ffx0; int by2=int(gl_FragCoord.y)-fy0;
                    if (bx>=0&&bx<fw&&by2>=0&&by2<fh) {
                        outColor = nameFocused
                            ? vec4(0.12,0.18,0.28,0.95)
                            : vec4(0.08,0.10,0.16,0.90);
                        if (bx==0||bx==fw-1||by2==0||by2==fh-1)
                            outColor = nameFocused ? vec4(0.4,0.6,1.0,1.0) : vec4(0.25,0.25,0.35,1.0);
                        else if (inTextInputStr(SC,ffx0+4,fy0+4,ubo.textLen,nameFocused))
                            outColor = vec4(1.0,1.0,1.0,1.0);
                        return;
                    }
                }

                // Seed field label "SEED:"  S(33)E(19)E(19)D(18):(14)
                int sy2 = fy + (CH+8)*2 + 8;
                if (inWord(SC,ffx0,sy2, 5, 33,19,19,18,14,100,100,100)) {
                    outColor=vec4(0.75,0.85,1.0,1.0); return;
                }
                {
                    bool seedFocused = (ubo.textField == 1);
                    int fy0=sy2+CH+4; int fh=CH+8;
                    int bx=int(gl_FragCoord.x)-ffx0; int by2=int(gl_FragCoord.y)-fy0;
                    if (bx>=0&&bx<fw&&by2>=0&&by2<fh) {
                        outColor = seedFocused
                            ? vec4(0.12,0.18,0.28,0.95)
                            : vec4(0.08,0.10,0.16,0.90);
                        if (bx==0||bx==fw-1||by2==0||by2==fh-1)
                            outColor = seedFocused ? vec4(0.4,0.6,1.0,1.0) : vec4(0.25,0.25,0.35,1.0);
                        else if (inSeedInputStr(SC,ffx0+4,fy0+4,ubo.seedLen,seedFocused))
                            outColor = vec4(1.0,1.0,1.0,1.0);
                        return;
                    }
                }

                // "CREATE" button
                {
                    int btnY2 = int(sh*0.72);
                    int btnX2 = cx - CW*3;
                    int bx=int(gl_FragCoord.x)-(btnX2-8); int by2=int(gl_FragCoord.y)-(btnY2-4);
                    int bw2=CW*6+16; int bh2=CH+8;
                    if (bx>=0&&bx<bw2&&by2>=0&&by2<bh2) {
                        outColor=vec4(0.15,0.35,0.15,0.92);
                        if (bx==0||bx==bw2-1||by2==0||by2==bh2-1)
                            outColor=vec4(0.3,0.8,0.3,1.0);
                        // C(17)R(32)E(19)A(15)T(34)E(19)
                        if (inWord(SC,btnX2,btnY2,6,17,32,19,15,34,19,100,100))
                            outColor=vec4(0.9,1.0,0.9,1.0);
                        return;
                    }
                }
                // "BACK" button
                {
                    int btnY3 = int(sh*0.82);
                    int btnX3 = cx - CW*2;
                    int bx=int(gl_FragCoord.x)-(btnX3-8); int by2=int(gl_FragCoord.y)-(btnY3-4);
                    int bw3=CW*4+16; int bh3=CH+8;
                    if (bx>=0&&bx<bw3&&by2>=0&&by2<bh3) {
                        outColor=vec4(0.25,0.12,0.12,0.90);
                        if (bx==0||bx==bw3-1||by2==0||by2==bh3-1)
                            outColor=vec4(0.8,0.3,0.3,1.0);
                        // B(16)A(15)C(17)K(25)
                        if (inWord(SC,btnX3,btnY3,4,16,15,17,25,100,100,100,100))
                            outColor=vec4(1.0,0.8,0.8,1.0);
                        return;
                    }
                }
            } else if (ubo.menuSubState == 2) {
                // ── World settings screen ─────────────────────────────────────
                // S(33)E(19)T(34)T(34)I(23)N(28)G(21)S(33) — "SETTINGS" title
                if (inWord(SC, cx-CW*4, int(sh*0.25), 8, 33,19,34,34,23,28,21,33)) {
                    outColor = vec4(0.9,1.0,0.9,1.0); return;
                }
                // Selected world name
                if (ubo.settingsWorldSel >= 0 && ubo.settingsWorldSel < ubo.worldCount) {
                    if (inWorldNameStr(ubo.settingsWorldSel, SC, cx-CW*6, int(sh*0.30))) {
                        outColor = vec4(0.7,0.85,1.0,1.0); return;
                    }
                }
                // Field: fw = min(CW*22+8, sw*7/10), ffx0 = cx-fw/2
                int fw2  = min(CW*22+8, int(sw*7/10));
                int ffx2 = cx - fw2/2;
                int renY = int(sh*0.38);
                // R(32)E(19)N(28)A(15)M(27)E(19):(14) — "RENAME:" label
                if (inWord(SC, ffx2, renY, 7, 32,19,28,15,27,19,14,100)) {
                    outColor = vec4(0.75,0.85,1.0,1.0); return;
                }
                // Rename input field box
                {
                    int fy0 = renY+CH+4; int fh2 = CH+8;
                    int bx2 = int(gl_FragCoord.x)-ffx2;
                    int by2 = int(gl_FragCoord.y)-fy0;
                    if (bx2 >= 0 && bx2 < fw2 && by2 >= 0 && by2 < fh2) {
                        outColor = vec4(0.12,0.18,0.28,0.95);
                        if (bx2==0||bx2==fw2-1||by2==0||by2==fh2-1)
                            outColor = vec4(0.4,0.6,1.0,1.0);
                        else if (inTextInputStr(SC, ffx2+4, fy0+4, ubo.textLen, true))
                            outColor = vec4(1.0,1.0,1.0,1.0);
                        return;
                    }
                }
                if (ubo.deleteConfirm == 0) {
                    // R(32)E(19)N(28)A(15)M(27)E(19) — "RENAME" button at sh*0.57
                    {
                        int btnY4 = int(sh*0.57); int btnX4 = cx-CW*3;
                        int bx2=int(gl_FragCoord.x)-(btnX4-8); int by2=int(gl_FragCoord.y)-(btnY4-4);
                        int bw4=CW*6+16; int bh4=CH+8;
                        if (bx2>=0&&bx2<bw4&&by2>=0&&by2<bh4) {
                            outColor=vec4(0.15,0.30,0.15,0.92);
                            if (bx2==0||bx2==bw4-1||by2==0||by2==bh4-1)
                                outColor=vec4(0.3,0.8,0.3,1.0);
                            if (inWord(SC,btnX4,btnY4,6,32,19,28,15,27,19,100,100))
                                outColor=vec4(0.9,1.0,0.9,1.0);
                            return;
                        }
                    }
                    // D(18)E(19)L(26)E(19)T(34)E(19) — "DELETE" button at sh*0.67
                    {
                        int btnY5 = int(sh*0.67); int btnX5 = cx-CW*3;
                        int bx2=int(gl_FragCoord.x)-(btnX5-8); int by2=int(gl_FragCoord.y)-(btnY5-4);
                        int bw5=CW*6+16; int bh5=CH+8;
                        if (bx2>=0&&bx2<bw5&&by2>=0&&by2<bh5) {
                            outColor=vec4(0.30,0.10,0.10,0.92);
                            if (bx2==0||bx2==bw5-1||by2==0||by2==bh5-1)
                                outColor=vec4(0.8,0.3,0.3,1.0);
                            if (inWord(SC,btnX5,btnY5,6,18,19,26,19,34,19,100,100))
                                outColor=vec4(1.0,0.8,0.8,1.0);
                            return;
                        }
                    }
                } else {
                    // Delete confirmation — "DELETE:" + "CONFIRM" text
                    // D(18)E(19)L(26)E(19)T(34)E(19):(14)
                    if (inWord(SC, cx-CW*4, int(sh*0.63), 7, 18,19,26,19,34,19,14,100)) {
                        outColor = vec4(1.0,0.5,0.3,1.0); return;
                    }
                    // C(17)O(29)N(28)F(20)I(23)R(32)M(27)
                    if (inWord(SC, cx-CW*4, int(sh*0.63)+CH+4, 7, 17,29,28,20,23,32,27,100)) {
                        outColor = vec4(1.0,0.5,0.3,1.0); return;
                    }
                    // Y(12)E(19)S(33) — "YES" button at sh*0.72, cx-CW*5
                    {
                        int btnY6 = int(sh*0.72); int btnX6 = cx-CW*5;
                        int bx2=int(gl_FragCoord.x)-(btnX6-8); int by2=int(gl_FragCoord.y)-(btnY6-4);
                        int bw6=CW*3+16; int bh6=CH+8;
                        if (bx2>=0&&bx2<bw6&&by2>=0&&by2<bh6) {
                            outColor=vec4(0.35,0.10,0.10,0.92);
                            if (bx2==0||bx2==bw6-1||by2==0||by2==bh6-1)
                                outColor=vec4(0.9,0.3,0.3,1.0);
                            if (inWord(SC,btnX6,btnY6,3,12,19,33,100,100,100,100,100))
                                outColor=vec4(1.0,0.8,0.8,1.0);
                            return;
                        }
                    }
                    // N(28)O(29) — "NO" button at sh*0.72, cx+CW*2
                    {
                        int btnY7 = int(sh*0.72); int btnX7 = cx+CW*2;
                        int bx2=int(gl_FragCoord.x)-(btnX7-8); int by2=int(gl_FragCoord.y)-(btnY7-4);
                        int bw7=CW*2+16; int bh7=CH+8;
                        if (bx2>=0&&bx2<bw7&&by2>=0&&by2<bh7) {
                            outColor=vec4(0.15,0.12,0.10,0.90);
                            if (bx2==0||bx2==bw7-1||by2==0||by2==bh7-1)
                                outColor=vec4(0.6,0.5,0.4,1.0);
                            if (inWord(SC,btnX7,btnY7,2,28,29,100,100,100,100,100,100))
                                outColor=vec4(0.9,0.9,0.8,1.0);
                            return;
                        }
                    }
                }
                // B(16)A(15)C(17)K(25) — "BACK" button at sh*0.82
                {
                    int btnY8 = int(sh*0.82); int btnX8 = cx-CW*2;
                    int bx2=int(gl_FragCoord.x)-(btnX8-8); int by2=int(gl_FragCoord.y)-(btnY8-4);
                    int bw8=CW*4+16; int bh8=CH+8;
                    if (bx2>=0&&bx2<bw8&&by2>=0&&by2<bh8) {
                        outColor=vec4(0.25,0.12,0.12,0.90);
                        if (bx2==0||bx2==bw8-1||by2==0||by2==bh8-1)
                            outColor=vec4(0.8,0.3,0.3,1.0);
                        if (inWord(SC,btnX8,btnY8,4,16,15,17,25,100,100,100,100))
                            outColor=vec4(1.0,0.8,0.8,1.0);
                        return;
                    }
                }
            }
            return;
        }

        // ==================================================================
        // PAUSE MENU (gameState == 2)
        // ==================================================================
        if (ubo.gameState == 2) {
            // Semi-transparent overlay
            int px = int(gl_FragCoord.x);
            int py = int(gl_FragCoord.y);
            int SC2 = max(3, int(sh) / 240);
            int CW2 = 4*SC2+SC2;
            int CH2 = 5*SC2;
            int cx2 = int(sw*0.5);
            int cy2 = int(sh*0.5);

            // Panel — BW scales with SC2 so buttons are readable at any resolution
            int PAD  = max(16, SC2 * 8);
            int BW   = max(240, CW2 * 16);
            int GAP  = max(10, SC2 * 4);
            int BH   = CH2 + max(10, SC2 * 4);
            const int PANEL_W = BW + PAD*2;
            const int PANEL_H = CH2+20 + 3*(BH+GAP) + PAD;

            int px0 = cx2 - PANEL_W/2;
            int py0 = cy2 - PANEL_H/2;
            int bpx = px - px0;
            int bpy = py - py0;

            if (bpx < 0 || bpx >= PANEL_W || bpy < 0 || bpy >= PANEL_H) {
                // Dim the world behind the menu
                outColor = vec4(0.0, 0.0, 0.0, 0.55);
                return;
            }

            // Panel background
            outColor = vec4(0.05, 0.06, 0.14, 0.94);
            if (bpx==0||bpx==PANEL_W-1||bpy==0||bpy==PANEL_H-1)
                outColor = vec4(0.4,0.5,0.9,1.0);

            // "PAUSED" title  P(30)A(15)U(35)S(33)E(19)D(18)
            int titX = cx2 - CW2*3;
            int titY = py0 + PAD;
            if (inWord(SC2,titX,titY, 6, 30,15,35,33,19,18,100,100)) {
                outColor=vec4(0.95,0.95,1.0,1.0); return;
            }

            // Three buttons
            int btnX3 = cx2 - BW/2;
            int btn0Y = py0 + PAD + CH2 + 20;
            int btn1Y = btn0Y + BH + GAP;
            int btn2Y = btn1Y + BH + GAP;

            // RESUME  R(32)E(19)S(33)U(35)M(27)E(19)
            {
                int bx2=px-btnX3; int by2=py-btn0Y;
                if (bx2>=0&&bx2<BW&&by2>=0&&by2<BH) {
                    outColor=vec4(0.12,0.28,0.12,0.92);
                    if(bx2==0||bx2==BW-1||by2==0||by2==BH-1) outColor=vec4(0.3,0.8,0.3,1.0);
                    if(inWord(SC2,btnX3+BW/2-CW2*3,btn0Y+7,6,32,19,33,35,27,19,100,100))
                        outColor=vec4(0.8,1.0,0.8,1.0);
                    return;
                }
            }
            // MAIN MENU  M(27)A(15)I(23)N(28) space M(27)E(19)N(28)U(35)
            {
                int bx2=px-btnX3; int by2=py-btn1Y;
                if (bx2>=0&&bx2<BW&&by2>=0&&by2<BH) {
                    outColor=vec4(0.12,0.15,0.28,0.92);
                    if(bx2==0||bx2==BW-1||by2==0||by2==BH-1) outColor=vec4(0.3,0.5,0.9,1.0);
                    // "MAIN" (4 chars)
                    if(inWord(SC2,btnX3+BW/2-CW2*5,btn1Y+7,4,27,15,23,28,100,100,100,100))
                        outColor=vec4(0.8,0.9,1.0,1.0);
                    // "MENU" (4 chars, after space)
                    if(inWord(SC2,btnX3+BW/2-CW2*5+CW2*5,btn1Y+7,4,27,19,28,35,100,100,100,100))
                        outColor=vec4(0.8,0.9,1.0,1.0);
                    return;
                }
            }
            // QUIT  Q(31)U(35)I(23)T(34)
            {
                int bx2=px-btnX3; int by2=py-btn2Y;
                if (bx2>=0&&bx2<BW&&by2>=0&&by2<BH) {
                    outColor=vec4(0.28,0.10,0.10,0.92);
                    if(bx2==0||bx2==BW-1||by2==0||by2==BH-1) outColor=vec4(0.9,0.3,0.3,1.0);
                    if(inWord(SC2,btnX3+BW/2-CW2*2,btn2Y+7,4,31,35,23,34,100,100,100,100))
                        outColor=vec4(1.0,0.7,0.7,1.0);
                    return;
                }
            }
            return;
        }

        // ==================================================================
        // QUITTING splash (gameState == 3)
        // ==================================================================
        if (ubo.gameState == 3) {
            outColor = vec4(0.02, 0.02, 0.08, 1.0);
            // "GOODBYE" = G(21)O(29)O(29)D(18)B(16)Y(12)E(19)
            int SC3=max(4,int(sh)/180); int CW3=4*SC3+SC3;
            int tx=int(sw*0.5)-CW3*3; int ty=int(sh*0.5)-5*SC3/2;
            if(inWord(SC3,tx,ty,7,21,29,29,18,16,12,19,100))
                outColor=vec4(0.8,0.8,1.0,1.0);
            return;
        }

        // ==================================================================
        // PLAYING state  (gameState == 1)
        // ==================================================================
        // Any other state (shouldn't reach here normally) — just discard
        if (ubo.gameState != 1) { discard; return; }

        int SC  = max(2, int(sh) / 360);
        int CHAR_W = 5 * SC;
        int CHAR_H = 5 * SC;
        const float PAD    = 4.0;
        int HUD_CHARS = 13 + valWidth(ubo.playerX) + valWidth(ubo.playerY) + valWidth(ubo.playerZ);
        int HUD_W     = HUD_CHARS * CHAR_W;
        float BG_Y0 = sh - float(CHAR_H) - PAD*2.0 - 2.0;
        float BG_Y1 = sh - 2.0;
        float BG_X0 = 4.0, BG_X1 = float(HUD_W) + 8.0;

        float hx = gl_FragCoord.x - (BG_X0 + PAD);
        float hy = gl_FragCoord.y - (BG_Y0 + PAD);
        if (gl_FragCoord.x >= BG_X0 && gl_FragCoord.x < BG_X1 &&
            gl_FragCoord.y >= BG_Y0 && gl_FragCoord.y < BG_Y1) {
            outColor = vec4(0.0, 0.0, 0.0, 0.72);
            if (hx >= 0.0 && hx < float(HUD_W) && hy >= 0.0 && hy < float(CHAR_H)) {
                int cIdx = int(hx) / CHAR_W;
                int gx   = (int(hx) % CHAR_W) / SC;
                int gy   = int(hy) / SC;
                // Odometer rolling for digit columns, static for labels/separators
                {
                    int wx   = valWidth(ubo.playerX);
                    int wy   = valWidth(ubo.playerY);
                    int offY = 3 + wx + 2;
                    int offZ = offY + 3 + wy + 2;
                    float vx = abs(ubo.camVX);
                    float vy = abs(ubo.camVY);
                    float vz = abs(ubo.camVZ);
                    bool lit = false;
                    if (cIdx >= 3 && cIdx < 3 + wx) {
                        lit = numOdometerPixel(ubo.playerX, abs(ubo.camWX), vx, cIdx - 3, gx, gy);
                    } else if (cIdx >= offY + 3 && cIdx < offY + 3 + wy) {
                        lit = numOdometerPixel(ubo.playerY, abs(ubo.camWY), vy, cIdx - (offY + 3), gx, gy);
                    } else if (cIdx >= offZ + 3) {
                        lit = numOdometerPixel(ubo.playerZ, abs(ubo.camWZ), vz, cIdx - (offZ + 3), gx, gy);
                    } else {
                        int code = hudCharCode(cIdx);
                        lit = gx < 4 && glyphPixel(digitGlyph(code), gx, gy);
                    }
                    if (lit) outColor = vec4(1.0, 1.0, 1.0, 1.0);
                }
            }
            return;
        }

        // ── World playtime timer (top-right)  HH:MM:SS ────────────────────
        {
            const int T_CHARS = 8;
            int T_W = T_CHARS * CHAR_W;
            float T_X0 = sw - float(T_W) - PAD * 2.0 - 2.0;
            float T_X1 = sw - 2.0;
            float T_Y0 = 2.0;
            float T_Y1 = float(CHAR_H) + PAD * 2.0 + 2.0;

            if (gl_FragCoord.x >= T_X0 && gl_FragCoord.x < T_X1 &&
                gl_FragCoord.y >= T_Y0 && gl_FragCoord.y < T_Y1) {
                outColor = vec4(0.0, 0.0, 0.0, 0.72);
                float tx = gl_FragCoord.x - (T_X0 + PAD);
                float ty = gl_FragCoord.y - (T_Y0 + PAD);
                if (tx >= 0.0 && tx < float(T_W) && ty >= 0.0 && ty < float(CHAR_H)) {
                    int ci = int(tx) / CHAR_W;
                    int gx = (int(tx) % CHAR_W) / SC;
                    int gy = int(ty) / SC;
                    if (timerOdometerPixel(ci, gx, gy))
                        outColor = vec4(1.0, 1.0, 1.0, 1.0);
                }
                return;
            }
        }

        // Planet info card
        if (ubo.planetVisible == 1) {
            int SC_p    = max(2, int(sh) / 360);
            int CW_p    = 5 * SC_p;
            int CH_p    = 5 * SC_p;
            const int COLS    = 16;
            const int PL_PAD  = 8;
            const int LINE_H  = CH_p + 2;
            const int CONTENT_W = COLS * CW_p;
            const int CONTENT_H = CH_p + 2 + 3*LINE_H + 2 + 3*LINE_H;
            const int BOX_W = CONTENT_W + PL_PAD*2 + 4;
            const int BOX_H = CONTENT_H + PL_PAD*2 + 4;

            vec3 bcolor;
            int bi = clamp(ubo.planetBiome, 0, 6);
            if      (bi == 0) bcolor = vec3(0.30, 0.75, 0.25);
            else if (bi == 1) bcolor = vec3(0.95, 0.72, 0.25);
            else if (bi == 2) bcolor = vec3(0.60, 0.85, 1.00);
            else if (bi == 3) bcolor = vec3(0.15, 0.55, 0.95);
            else if (bi == 4) bcolor = vec3(0.25, 0.55, 0.18);
            else if (bi == 5) bcolor = vec3(0.90, 0.30, 0.05);
            else              bcolor = vec3(0.10, 0.65, 0.18);

            int lx0 = ubo.planetScreenX + ubo.planetScreenR;
            int ly0 = ubo.planetScreenY - BOX_H / 2;
            lx0 = clamp(lx0, 3, int(sw) - BOX_W - 3);
            ly0 = clamp(ly0, 3, int(sh) - BOX_H - 3);

            int bx = int(gl_FragCoord.x) - lx0;
            int by = int(gl_FragCoord.y) - ly0;

            if (bx >= 0 && bx < BOX_W && by >= 0 && by < BOX_H) {
                if (bx == 0 || bx == BOX_W-1 || by == 0 || by == BOX_H-1) {
                    outColor = vec4(bcolor, 0.95); return;
                }
                if (bx == 1 || bx == BOX_W-2 || by == 1 || by == BOX_H-2) {
                    outColor = vec4(bcolor * 0.40, 0.70); return;
                }
                outColor = vec4(0.03, 0.04, 0.10, 0.92);
                int cx2 = bx - 2 - PL_PAD;
                int cy2 = by - 2 - PL_PAD;
                if (cx2 < 0 || cx2 >= CONTENT_W || cy2 < 0) return;
                int charCol = cx2 / CW_p;
                int gx      = (cx2 % CW_p) / SC_p;
                int glyphRow = 0;
                int gy       = 0;
                bool isSep   = false;
                if (cy2 < CH_p) {
                    glyphRow = 0; gy = cy2 / SC_p;
                } else if (cy2 < CH_p + 2) {
                    isSep = true;
                } else {
                    int cy3 = cy2 - (CH_p + 2);
                    if      (cy3 < LINE_H)       { glyphRow = 1; gy = cy3 / SC_p; }
                    else if (cy3 < 2*LINE_H)     { glyphRow = 2; gy = (cy3 - LINE_H) / SC_p; }
                    else if (cy3 < 3*LINE_H)     { glyphRow = 3; gy = (cy3 - 2*LINE_H) / SC_p; }
                    else if (cy3 < 3*LINE_H + 2) { isSep = true; }
                    else {
                        int cy4 = cy3 - (3*LINE_H + 2);
                        if      (cy4 < LINE_H)   { glyphRow = 4; gy = cy4 / SC_p; }
                        else if (cy4 < 2*LINE_H) { glyphRow = 5; gy = (cy4 - LINE_H) / SC_p; }
                        else if (cy4 < 3*LINE_H) { glyphRow = 6; gy = (cy4 - 2*LINE_H) / SC_p; }
                        else return;
                    }
                }
                if (isSep) { outColor = vec4(bcolor * 0.50, 0.55); return; }
                if (gx < 4 && gy < 5) {
                    int code = planetCharCode(glyphRow, charCol);
                    if (glyphPixel(digitGlyph(code), gx, gy)) {
                        outColor = (glyphRow == 0)
                            ? vec4(1.00, 0.88, 0.28, 1.0)
                            : vec4(0.78, 0.95, 1.00, 1.0);
                        return;
                    }
                }
                return;
            }
        }

        // Slot size scales with resolution: 48px at 720p, 96px at 1440p, 144px at 2160p
        int SLOT_SZ  = max(48, int(sh) / 15);
        int SLOT_BRD = max(3, SLOT_SZ / 12);

        // ── Inventory panel (when invOpen) ────────────────────────────────
        if (ubo.invOpen == 1) {
            // 4×4 grid centred on screen
            int GAP2  = max(4, SLOT_SZ / 12);
            int GRID  = 4;
            int PANEL_W2 = GRID*SLOT_SZ + (GRID-1)*GAP2 + 16;
            int PANEL_H2 = GRID*SLOT_SZ + (GRID-1)*GAP2 + 16;
            int px0 = int(sw*0.5) - PANEL_W2/2;
            int py0 = int(sh*0.5) - PANEL_H2/2;

            int bpx = int(gl_FragCoord.x) - px0;
            int bpy = int(gl_FragCoord.y) - py0;

            if (bpx >= 0 && bpx < PANEL_W2 && bpy >= 0 && bpy < PANEL_H2) {
                outColor = vec4(0.06, 0.07, 0.10, 0.90);
                if (bpx==0||bpx==PANEL_W2-1||bpy==0||bpy==PANEL_H2-1) {
                    outColor = vec4(0.5,0.5,0.5,1.0); return;
                }

                int cx3 = bpx - 8;
                int cy3 = bpy - 8;
                int col3 = cx3 / (SLOT_SZ + GAP2);
                int row3 = cy3 / (SLOT_SZ + GAP2);
                if (col3 < 0 || col3 >= 4 || row3 < 0 || row3 >= 4) return;

                int sx3 = cx3 - col3 * (SLOT_SZ + GAP2);
                int sy3 = cy3 - row3 * (SLOT_SZ + GAP2);
                if (sx3 < 0 || sx3 >= SLOT_SZ || sy3 < 0 || sy3 >= SLOT_SZ) return;

                int slotIdx = row3 * 4 + col3;
                bool isHotbarSlot = (row3 == 3) && (col3 == ubo.hotbarSlot);
                renderSlot(slotIdx, sx3, sy3, isHotbarSlot, SLOT_SZ, SLOT_BRD);
                return;
            }
        }

        // ── Hotbar (always shown when playing) ────────────────────────────
        {
            int GAP3   = max(4, SLOT_SZ / 12);
            int NSLOT  = 4;
            int BAR_W  = NSLOT*SLOT_SZ + (NSLOT-1)*GAP3;
            int bx0 = int(sw*0.5) - BAR_W/2;
            int by0 = int(sh) - SLOT_SZ - 10;

            int bpx = int(gl_FragCoord.x) - bx0;
            int bpy = int(gl_FragCoord.y) - by0;

            if (bpx >= 0 && bpx < BAR_W && bpy >= 0 && bpy < SLOT_SZ) {
                // Dark background covers full bar including gaps between slots
                outColor = vec4(0.06, 0.06, 0.08, 0.82);
                int col4 = bpx / (SLOT_SZ + GAP3);
                int sx4  = bpx - col4 * (SLOT_SZ + GAP3);
                if (col4 >= 0 && col4 < NSLOT && sx4 >= 0 && sx4 < SLOT_SZ) {
                    int slotIdx = 12 + col4;
                    bool sel = (col4 == ubo.hotbarSlot);
                    renderSlot(slotIdx, sx4, bpy, sel, SLOT_SZ, SLOT_BRD);
                }
                return;
            }
        }

        // ── HP hearts (8 hearts = 16 HP, always shown) ───────────────────
        {
            int NHEART = 8;            // total hearts (2 HP each)
            int HSIZ   = max(8, SLOT_SZ / 4);   // heart icon size in px
            int HGAP   = max(2, HSIZ / 5);
            int HP_W   = NHEART * HSIZ + (NHEART - 1) * HGAP;
            int hx0    = int(sw * 0.5) - HP_W / 2;
            int hy0    = int(sh) - SLOT_SZ - 10 - HSIZ - 6;
            int hpx    = int(gl_FragCoord.x) - hx0;
            int hpy    = int(gl_FragCoord.y) - hy0;
            if (hpx >= 0 && hpx < HP_W && hpy >= 0 && hpy < HSIZ) {
                int hIdx = hpx / (HSIZ + HGAP);
                int hsx  = hpx - hIdx * (HSIZ + HGAP);
                if (hIdx >= 0 && hIdx < NHEART && hsx >= 0 && hsx < HSIZ) {
                    // Analytic heart shape: map pixel to [-1,1]x[-1,1]
                    float hfx = (float(hsx) / float(HSIZ)) * 2.0 - 1.0;
                    float hfy = 1.0 - (float(hpy) / float(HSIZ)) * 2.0; // y up
                    hfx *= 1.1; hfy = hfy * 1.1 - 0.1; // slight scale/shift
                    float v = pow(hfx*hfx + hfy*hfy - 1.0, 3.0) - hfx*hfx * hfy*hfy*hfy;
                    bool inHeart = (v < 0.05);
                    if (inHeart) {
                        int hpThreshold = hIdx * 2 + 2; // HP needed to fill this heart
                        bool full    = (ubo.playerHp >= hpThreshold);
                        bool partial = (!full && ubo.playerHp >= hpThreshold - 1);
                        if (full)         outColor = vec4(0.90, 0.15, 0.15, 1.0);
                        else if (partial) outColor = vec4(0.55, 0.10, 0.10, 1.0);
                        else              outColor = vec4(0.20, 0.08, 0.08, 0.85);
                        return;
                    }
                }
            }
        }

        // ── Air bubbles (show when airPercent < 100) ──────────────────────
        if (ubo.airPercent < 100) {
            int BSIZ = max(8, SLOT_SZ / 5);
            int BGAP = max(2, BSIZ / 5);
            int NBUB = 10;
            int AIR_W = NBUB * BSIZ + (NBUB - 1) * BGAP;
            int abx0  = int(sw * 0.5) - AIR_W / 2;
            int aby0  = int(sh) - SLOT_SZ - 10 - BSIZ - 6;
            int abpx  = int(gl_FragCoord.x) - abx0;
            int abpy  = int(gl_FragCoord.y) - aby0;
            if (abpx >= 0 && abpx < AIR_W && abpy >= 0 && abpy < BSIZ) {
                int bIdx = abpx / (BSIZ + BGAP);
                int bsx  = abpx - bIdx * (BSIZ + BGAP);
                if (bIdx >= 0 && bIdx < NBUB && bsx >= 0 && bsx < BSIZ) {
                    int threshold = bIdx * 10 + 10;
                    bool filled   = (threshold <= ubo.airPercent);
                    bool partial  = (!filled && (bIdx * 10 < ubo.airPercent));
                    if (filled)        outColor = vec4(0.30, 0.62, 1.00, 0.92);
                    else if (partial)  outColor = vec4(0.15, 0.35, 0.70, 0.85);
                    else               outColor = vec4(0.05, 0.06, 0.10, 0.75);
                    return;
                }
            }
        }

        // ── Break crack overlay ──────────────────────────────────────────────
        if (ubo.breakProgress > 0.0 && ubo.gameState == 0) {
            float cx2 = sw * 0.5, cy2 = sh * 0.5;
            float nx2 = (gl_FragCoord.x - cx2) / (sw * 0.4);
            float ny2 = (gl_FragCoord.y - cy2) / (sh * 0.4);
            float dist2 = sqrt(nx2*nx2 + ny2*ny2);
            if (dist2 < 1.0) {
                float angle = atan(ny2, nx2);
                float crackA = 0.0;
                for (int ci = 0; ci < 6; ci++) {
                    float cang = angle - float(ci) * 1.047;
                    cang = mod(cang + 3.14159, 6.28318) - 3.14159;
                    float cw2 = 0.07 + ubo.breakProgress * 0.1;
                    float cv = max(0.0, 1.0 - abs(cang) / cw2);
                    float radFade = max(0.0, 1.0 - abs(dist2 - ubo.breakProgress) / 0.3);
                    crackA = max(crackA, cv * radFade);
                }
                outColor = mix(outColor, vec4(0.0,0.0,0.0,1.0), crackA * ubo.breakProgress * 0.85);
            }
        }

        // ── Crafting panel (shown when inventory open) ────────────────────
        if (ubo.invOpen == 1) {
            int CGAP = max(4, SLOT_SZ / 12);
            int PANEL_W3 = 4 * SLOT_SZ + 3 * CGAP + 16;
            int PANEL_H3 = 4 * SLOT_SZ + 3 * CGAP + 16;
            int px03 = int(sw * 0.5) - PANEL_W3 / 2;
            int py03 = int(sh * 0.5) - PANEL_H3 / 2;
            int CRAFT_X = px03 + PANEL_W3 + 20;
            int CRAFT_Y = py03;

            // 3x3 craft grid
            int craftPacked[9];
            craftPacked[0] = ubo.craft0; craftPacked[1] = ubo.craft1; craftPacked[2] = ubo.craft2;
            craftPacked[3] = ubo.craft3; craftPacked[4] = ubo.craft4; craftPacked[5] = ubo.craft5;
            craftPacked[6] = ubo.craft6; craftPacked[7] = ubo.craft7; craftPacked[8] = ubo.craft8;

            for (int ci2 = 0; ci2 < 9; ci2++) {
                int ccol = ci2 % 3;
                int crow = ci2 / 3;
                int slotX = CRAFT_X + ccol * (SLOT_SZ + CGAP) + 8;
                int slotY = CRAFT_Y + crow * (SLOT_SZ + CGAP) + 8;
                int cpx = int(gl_FragCoord.x) - slotX;
                int cpy = int(gl_FragCoord.y) - slotY;
                if (cpx >= 0 && cpx < SLOT_SZ && cpy >= 0 && cpy < SLOT_SZ) {
                    outColor = vec4(0.15, 0.15, 0.15, 0.90);
                    int cbtype = (craftPacked[ci2] >> 16) & 0xFFFF;
                    int iconSz3 = SLOT_SZ - 2 * SLOT_BRD;
                    if (cpx >= SLOT_BRD && cpx < SLOT_SZ - SLOT_BRD &&
                        cpy >= SLOT_BRD && cpy < SLOT_SZ - SLOT_BRD && cbtype > 0) {
                        vec2 iconUV2 = vec2(float(cpx - SLOT_BRD) / float(iconSz3),
                                           float(cpy - SLOT_BRD) / float(iconSz3));
                        vec4 ctex = sampleBlockIcon(cbtype, iconUV2);
                        if (ctex.a > 0.05) outColor = vec4(ctex.rgb, 1.0);
                    }
                    return;
                }
            }

            // Output slot (right of 3x3 grid, vertically centered in 3 rows)
            int outX = CRAFT_X + 3 * (SLOT_SZ + CGAP) + 30;
            int outY = CRAFT_Y + (SLOT_SZ + CGAP) + 8;
            int opx = int(gl_FragCoord.x) - outX;
            int opy = int(gl_FragCoord.y) - outY;
            if (opx >= 0 && opx < SLOT_SZ && opy >= 0 && opy < SLOT_SZ) {
                outColor = vec4(0.10, 0.18, 0.10, 0.90);
                int obtype = (ubo.craftOut >> 16) & 0xFFFF;
                int iconSz4 = SLOT_SZ - 2 * SLOT_BRD;
                if (opx >= SLOT_BRD && opx < SLOT_SZ - SLOT_BRD &&
                    opy >= SLOT_BRD && opy < SLOT_SZ - SLOT_BRD && obtype > 0) {
                    vec2 iconUV3 = vec2(float(opx - SLOT_BRD) / float(iconSz4),
                                       float(opy - SLOT_BRD) / float(iconSz4));
                    vec4 otex = sampleBlockIcon(obtype, iconUV3);
                    if (otex.a > 0.05) outColor = vec4(otex.rgb, 1.0);
                }
                return;
            }

            // Recipe list panel (right of output slot)
            // REC_OUT: Stick=12, WoodPick=13, StonePick=14, WoodAxe=15, StoneAxe=16, WoodSword=17, StoneSword=18
            int REC_OUT[7]  = int[](12, 13, 14, 15, 16, 17, 18);
            int REC_CNT[7]  = int[]( 4,  1,  1,  1,  1,  1,  1);
            int REC_I1T[7]  = int[]( 7,  7,  3,  7,  3,  7,  3); // Wood=7, Stone=3
            int REC_I1C[7]  = int[]( 2,  3,  3,  3,  3,  2,  2);
            int REC_I2T[7]  = int[]( 0, 12, 12, 12, 12, 12, 12); // Stick=12, 0=none
            int REC_I2C[7]  = int[]( 0,  2,  2,  2,  2,  1,  1);

            int RECIPE_X = CRAFT_X + 3*(SLOT_SZ+CGAP) + SLOT_SZ + 50;
            int RECIPE_Y = CRAFT_Y;
            int RECIPE_ROW_H = SLOT_SZ + CGAP;
            int SMALL_SZ = SLOT_SZ / 2;
            int ROW_W = SLOT_SZ + 2*SMALL_SZ + CGAP*2 + 8;

            for (int ri = 0; ri < 7; ri++) {
                int rowX = RECIPE_X;
                int rowY = RECIPE_Y + ri * RECIPE_ROW_H;
                int rpx = int(gl_FragCoord.x) - rowX;
                int rpy = int(gl_FragCoord.y) - rowY;
                if (rpx < 0 || rpx >= ROW_W || rpy < 0 || rpy >= SLOT_SZ) continue;

                bool avail = ((ubo.recipeAvail >> ri) & 1) == 1;
                // Background: green if available, dark gray otherwise
                outColor = avail ? vec4(0.10, 0.22, 0.10, 0.90) : vec4(0.12, 0.12, 0.12, 0.90);

                // Output icon (full SLOT_SZ on left)
                if (rpx < SLOT_SZ) {
                    int iconSzR = SLOT_SZ - 2*SLOT_BRD;
                    if (rpx >= SLOT_BRD && rpx < SLOT_SZ - SLOT_BRD &&
                        rpy >= SLOT_BRD && rpy < SLOT_SZ - SLOT_BRD) {
                        vec2 riUV = vec2(float(rpx - SLOT_BRD) / float(iconSzR),
                                         float(rpy - SLOT_BRD) / float(iconSzR));
                        vec4 rtex = sampleBlockIcon(REC_OUT[ri], riUV);
                        if (rtex.a > 0.05) outColor = vec4(rtex.rgb, 1.0);
                    }
                }

                // Ingredient 1 small icon (upper right)
                int ing1X = rpx - (SLOT_SZ + CGAP);
                int ing1Y = rpy;
                if (ing1X >= 0 && ing1X < SMALL_SZ && ing1Y >= 0 && ing1Y < SMALL_SZ && REC_I1T[ri] > 0) {
                    bool i1avail = countInv(REC_I1T[ri]) >= REC_I1C[ri];
                    vec2 i1UV = vec2(float(ing1X)/float(SMALL_SZ), float(ing1Y)/float(SMALL_SZ));
                    vec4 i1tex = sampleBlockIcon(REC_I1T[ri], i1UV);
                    if (i1tex.a > 0.05) {
                        outColor = i1avail ? vec4(i1tex.rgb, 1.0) : vec4(i1tex.r*0.7+0.3, i1tex.g*0.3, i1tex.b*0.3, 1.0);
                    }
                }

                // Ingredient 2 small icon (lower right, below ing1)
                int ing2X = rpx - (SLOT_SZ + CGAP);
                int ing2Y = rpy - SMALL_SZ;
                if (ing2X >= 0 && ing2X < SMALL_SZ && ing2Y >= 0 && ing2Y < SMALL_SZ && REC_I2T[ri] > 0) {
                    bool i2avail = countInv(REC_I2T[ri]) >= REC_I2C[ri];
                    vec2 i2UV = vec2(float(ing2X)/float(SMALL_SZ), float(ing2Y)/float(SMALL_SZ));
                    vec4 i2tex = sampleBlockIcon(REC_I2T[ri], i2UV);
                    if (i2tex.a > 0.05) {
                        outColor = i2avail ? vec4(i2tex.rgb, 1.0) : vec4(i2tex.r*0.7+0.3, i2tex.g*0.3, i2tex.b*0.3, 1.0);
                    }
                }

                return;
            }
        }

        // ── Held item follows cursor when inventory is open ────────────────
        if (ubo.invOpen == 1 && ubo.heldType > 0) {
            int HELD_SZ = SLOT_SZ * 3 / 4;
            int hx = ubo.cursorX - HELD_SZ/2;
            int hy = ubo.cursorY - HELD_SZ/2;
            int hpx = int(gl_FragCoord.x) - hx;
            int hpy = int(gl_FragCoord.y) - hy;
            if (hpx >= 0 && hpx < HELD_SZ && hpy >= 0 && hpy < HELD_SZ) {
                outColor = vec4(0.08, 0.08, 0.08, 0.70);
                vec2 iconUV = vec2(float(hpx)/float(HELD_SZ), float(hpy)/float(HELD_SZ));
                vec4 tex = sampleBlockIcon(ubo.heldType, iconUV);
                if (tex.a > 0.05) outColor = vec4(tex.rgb, 0.92);
                return;
            }
        }

        // ── Crosshair ─────────────────────────────────────────────────────
        vec2 center  = vec2(sw * 0.5, sh * 0.5);
        vec2 d       = abs(gl_FragCoord.xy - center);
        bool inner_h = d.x <= 9.0  && d.y <= 1.5;
        bool inner_v = d.y <= 9.0  && d.x <= 1.5;
        bool outer_h = d.x <= 10.5 && d.y <= 3.0;
        bool outer_v = d.y <= 10.5 && d.x <= 3.0;
        if (!outer_h && !outer_v) discard;
        outColor = (inner_h || inner_v)
            ? vec4(1.0, 1.0, 1.0, 1.0)
            : vec4(0.0, 0.0, 0.0, 0.85);
        return;
    }

    // ── Sky planet disc (texIndex ≈ −1.25) — procedural surface ─────────
    // MUST come before the star check (−1.25 < −0.625 so star branch would catch it first)
    if (fragTexIndex < -1.0) {
        vec2  uv = fragUV - 0.5;
        float r  = length(uv) * 2.0;
        if (r > 1.0) discard;

        // Sphere surface normal (Z toward camera)
        float nz = sqrt(max(0.0, 1.0 - r * r));
        vec3  N  = vec3(uv.x * 2.0, uv.y * 2.0, nz);

        // Decode: col.xy = unique position-based seed, col.z = biome index
        vec2 seed  = fragColor.xy;
        int  biome = clamp(int(fragColor.z * 7.0), 0, 6);

        // Per-biome color palettes
        // [0]=Temperate [1]=Desert [2]=Snow [3]=Ocean [4]=Swamp [5]=Volcanic [6]=Forest
        vec3 oceanC[7] = vec3[7](
            vec3(0.06, 0.16, 0.52),   // Temperate: atlantic blue
            vec3(0.42, 0.22, 0.08),   // Desert: iron-oxide dust sea
            vec3(0.50, 0.68, 0.90),   // Snow: pale glacial ocean
            vec3(0.02, 0.08, 0.48),   // Ocean: abyssal blue
            vec3(0.10, 0.18, 0.08),   // Swamp: murky brown-green
            vec3(0.10, 0.05, 0.04),   // Volcanic: black basalt sea
            vec3(0.04, 0.20, 0.06)    // Forest: dark canopy shadow
        );
        vec3 landC[7] = vec3[7](
            vec3(0.20, 0.54, 0.16),   // Temperate: green continents
            vec3(0.76, 0.50, 0.20),   // Desert: sand dunes
            vec3(0.84, 0.90, 1.00),   // Snow: bright ice sheet
            vec3(0.04, 0.36, 0.20),   // Ocean: algae/reef shallows
            vec3(0.22, 0.38, 0.10),   // Swamp: olive mud flats
            vec3(0.50, 0.16, 0.05),   // Volcanic: lava flows
            vec3(0.12, 0.46, 0.10)    // Forest: dense canopy
        );
        vec3 iceC[7] = vec3[7](
            vec3(0.90, 0.94, 1.00),   // Temperate: white polar caps
            vec3(0.82, 0.72, 0.60),   // Desert: pale dust cap
            vec3(1.00, 1.00, 1.00),   // Snow: blinding white
            vec3(0.72, 0.88, 1.00),   // Ocean: blue ice
            vec3(0.68, 0.74, 0.60),   // Swamp: murky frost
            vec3(0.38, 0.28, 0.26),   // Volcanic: ash grey
            vec3(0.78, 0.90, 0.72)    // Forest: misty canopy top
        );
        // Cloud density, land threshold, ice threshold per biome (base values)
        float cloudAmt[7]  = float[7](0.72, 0.20, 0.88, 0.62, 0.50, 0.08, 0.78);
        float landThr[7]   = float[7](0.48, 0.38, 0.32, 0.64, 0.50, 0.44, 0.45);
        float iceThr[7]    = float[7](0.64, 0.82, 0.48, 0.72, 0.74, 0.88, 0.70);

        // Derive extra per-planet seeds from the two seed channels.
        // These scatter widely so each planet gets distinct visual parameters.
        float s1 = fract(seed.x * 13.71 + seed.y *  5.37);  // cloud / terrain variant
        float s2 = fract(seed.y * 17.31 + seed.x *  7.13);  // color tint variant
        float s3 = fract(seed.x *  9.43 + seed.y * 11.87);  // terrain scale variant

        // Per-planet threshold & cloud variation around biome defaults
        float lt = clamp(landThr[biome] + (seed.x - 0.5) * 0.26, 0.16, 0.80);
        float it = clamp(iceThr[biome]  + (seed.y - 0.5) * 0.22, 0.48, 0.97);
        float ca = cloudAmt[biome] * (0.30 + s1 * 1.40);   // 30 % … 170 % of biome base

        // Per-planet color tinting: subtle warm/cool shift inside the biome palette
        float tint  = s2 * 0.22 - 0.11;         // −0.11 .. +0.11
        vec3  lC    = clamp(landC[biome]  + vec3(tint * 0.6,  tint,       tint * 0.3),  0.0, 1.0);
        vec3  oC    = clamp(oceanC[biome] + vec3(-tint * 0.3, tint * 0.2, tint * 0.5),  0.0, 1.0);
        vec3  iC    = iceC[biome];

        // Slow axial rotation unique per planet
        float rotRate = 0.008 + seed.x * 0.018;
        float rotA    = ubo.gameTime * rotRate;
        float cr = cos(rotA), sr = sin(rotA);
        vec3  Nr = vec3(N.x*cr + N.z*sr, N.y, -N.x*sr + N.z*cr);

        // Spherical UV for seamless surface sampling
        vec2 sUV = vec2(atan(Nr.z, Nr.x) * 0.15915,           // lon [-0.5,0.5]
                        asin(clamp(Nr.y,-1.0,1.0)) * 0.31831) // lat [-0.5,0.5]
                 * 1.8 + seed;

        // Terrain FBM — land/ocean mask; scale varies ±20 % per planet
        float tScale = 2.2 + s3 * 0.7;
        float terrain = clamp(
            cloudFBM(sUV * tScale) + cloudFBM(sUV * (tScale * 2.1) + vec2(3.14,1.59)) * 0.28,
            0.0, 1.0);

        float pole  = abs(N.y);
        float iceF  = smoothstep(it, it + 0.16, pole);
        float landF = smoothstep(lt, lt + 0.14, terrain);

        vec3 surface = mix(oC, lC, landF);
        surface      = mix(surface, iC, iceF);
        // Coastal darkening
        surface     *= mix(0.78, 1.0, smoothstep(lt, lt + 0.08, terrain));

        // Cloud layer — offset and speed also vary per planet
        vec2  cUV = sUV * 1.3 + vec2(5.47 + s1 * 3.1, 2.31 + s2 * 2.7)
                  + ubo.gameTime * (0.003 + s3 * 0.005);
        float cf  = smoothstep(0.46, 0.65, cloudFBM(cUV * 2.1))
                  * ca * (1.0 - iceF * 0.45);
        surface = mix(surface, vec3(0.94, 0.96, 1.00), cf * 0.83);

        // Diffuse + ambient lighting from the game's sun
        vec3  sun  = normalize(vec3(ubo.sunDirX, ubo.sunDirY, ubo.sunDirZ));
        float diff = max(0.0, dot(N, sun));
        surface   *= 0.06 + 0.94 * diff;

        // Specular glint on ocean surface only
        vec3  H    = normalize(sun + vec3(0.0, 0.0, 1.0));
        float spec = pow(max(0.0, dot(N, H)), 26.0)
                   * (1.0 - landF) * (1.0 - cf) * diff;
        surface   += vec3(0.45, 0.60, 0.82) * spec * 0.40;

        // Atmosphere rim glow: sunlit side warm, dark side cold
        float rimF   = pow(1.0 - nz, 2.5);
        float sunRim = max(0.0, dot(uv, sun.xy));
        vec3  atmC   = mix(vec3(0.12, 0.28, 0.88), lC * 0.7 + 0.05, 0.45);
        surface += atmC * rimF * (0.20 + sunRim * 0.62);

        outColor = vec4(clamp(surface, 0.0, 1.0), smoothstep(1.0, 0.90, r));
        return;
    }

    // ── Star (texIndex ≈ −0.75) ──────────────────────────────────────────
    if (fragTexIndex < -0.625) {
        vec2  uv = fragUV - vec2(0.5);
        float r  = length(uv) * 2.0;
        if (r > 1.0) discard;
        // In space stars are always fully visible; on surface fade by day/night
        float nightF = max(ubo.spaceBlend, clamp(-ubo.sunDirY * 4.5 + 0.3, 0.0, 1.0));
        float brightness = (exp(-r*r*3.5)*0.6 + smoothstep(0.35, 0.0, r)) * nightF;
        if (brightness < 0.005) discard;
        outColor = vec4(fragColor * brightness, brightness);
        return;
    }

    if (fragTexIndex < -0.5) {
        outColor = vec4(fragColor, 1.0);
        return;
    }

    // ── Block texture atlas ───────────────────────────────────────────────
    float tile  = floor(fragTexIndex + 0.5);
    float tileX = mod(tile, ATLAS_COLUMNS);
    float tileY = floor(tile / ATLAS_COLUMNS);
    vec2 localUV    = clamp(fragUV, vec2(0.0), vec2(0.9999));
    vec2 atlasMin   = vec2(tileX / ATLAS_COLUMNS, tileY / ATLAS_ROWS);
    vec2 atlasMax   = vec2((tileX+1.0) / ATLAS_COLUMNS, (tileY+1.0) / ATLAS_ROWS);
    vec2 atlasTexel = vec2(1.0 / (ATLAS_COLUMNS*FACE_SIZE), 1.0 / (ATLAS_ROWS*FACE_SIZE));
    vec2 sampleUV   = mix(atlasMin + 0.5*atlasTexel, atlasMax - 0.5*atlasTexel, localUV);

    vec4 texel = texture(atlasSampler, sampleUV);
    if (texel.a < 0.1) discard;

    vec3  rgb   = texel.rgb * fragColor;
    float alpha = texel.a;

    // ── Animated water ───────────────────────────────────────────────────
    if (isWaterTile(tile)) {
        // Two slightly offset UV samples scrolled in opposite directions → ripple
        float t1 = ubo.gameTime * 0.016;
        float t2 = ubo.gameTime * 0.011;
        vec2 scrollA = sampleUV + vec2( t1,  t2 * 0.7) * (atlasMax - atlasMin);
        vec2 scrollB = sampleUV + vec2(-t2,  t1 * 0.5) * (atlasMax - atlasMin);
        scrollA = clamp(scrollA, atlasMin + atlasTexel, atlasMax - atlasTexel);
        scrollB = clamp(scrollB, atlasMin + atlasTexel, atlasMax - atlasTexel);
        vec4 wa = texture(atlasSampler, scrollA);
        vec4 wb = texture(atlasSampler, scrollB);
        vec3 waterBase = mix(wa.rgb, wb.rgb, 0.5) * fragColor;
        // Fresnel-like darkening toward edges + deep blue tint
        float vDot = clamp(1.0 - abs(fragUV.y - 0.5) * 1.8, 0.0, 1.0);
        waterBase = mix(waterBase, vec3(0.02, 0.09, 0.36), 0.50 + vDot * 0.15);
        rgb   = waterBase;
        alpha = 0.72;

    }

    // ── Day/night global lighting ────────────────────────────────────────
    float elev  = ubo.sunDirY;
    float sunI  = sunIntensity(elev);
    vec3  sunC  = sunLightColour(elev);
    float ambnt = mix(0.035, 0.18, clamp(elev * 3.0 + 0.5, 0.0, 1.0)); // night→day ambient
    rgb = rgb * (ambnt + sunI * sunC * (1.0 - ambnt));

    // ── ACES tonemapping + colour grading ───────────────────────────────
    rgb = saturate(rgb, 1.18);            // slight saturation boost
    rgb = aces(rgb * 1.15);               // filmic curve with +15% exposure
    rgb = pow(rgb, vec3(0.96));           // mild gamma lift for punch

    outColor = vec4(rgb, alpha);

    // Underwater tint
    if (ubo.underWater != 0) {
        float extra = (ubo.airPercent < 10) ? 0.10 : 0.0;
        outColor.rgb = mix(outColor.rgb, vec3(0.02, 0.06, 0.22), 0.28 + extra);
    }
}
