Page featuring more displays and sketches

Lilygo T-Display: sntp clock with multiple animations



An SNTP clock with multiple animations. This sketch is written for the Lilygo T-Display (240*135 px). We do not use animated GIFs. We use the graphics functions of the "TFT_eSPI.h" library to generate the animations.
The two buttons on the front of the development board allow you to rotate the screen, and switch between different animations.

The settings are stored in FLASH memory so that the selection is retained at the next startup. To avoid wearing out the FLASH memory, storage is delayed by 5 minutes, and writing is done only if there are changes.

If WiFi cannot be connected at startup, the sketch will launch a web server where you can enter the WiFi credentials, as well as the time zone. That way, the clock is always correct. Adjustments to daylight saving time or standard time are done automatically.

In most online source codes for clocks for the ESP32 or Arduino, this is not automatically adjusted, and you would need to recompile the sketch twice a year. This is not a problem for the hobbyist, but in this way, you can't, for example, gift a self-made clock to someone, let alone develop a commercial application.

Explanation of the fonts used here:

You can use any font from your own PC (or a downloaded font) on the ESP32.
Let's try the font "HP Simplified Bold" in this example.
If you don't have this on your PC you can download it from https://fonnts.com/hp-simplified/
Save this font somewhere you can find it.

Then click https://rop.nl/truetype2gfx/
Select and upload the font file.

Important: You must rename the font file before uploading it. The name is used in a few places in the library itself, so renaming afterwards will result in error messages. In this example, we rename it "HPSimplified.ttf".

Choose your desired size (e.g. 40 pt)
Then click on the button "Get GFX font file".

You can then download the font as, e.g. "HPSimplified40pt7b.h". Place it in the same folder as the sketch. If you wish to compile the sketch below, repeat for size 20pt: "HPSimplified20pt7b.h". These names should match those in the sketch.

In the TFT_eSPI library used, a file is provided in the User_Setups folder for this board: Setup206_LilyGo_T_Display_S3.h

Sketch:

/*===============================================================================================================
This sketch was written for the Lilygo T-display (ESP32) with a display of 240 * 135 pixels. There are multiple 
animations, selectable with the flash button (GPIO 0). The orientation of the display can be selected with the 
button opposite the flash button (GPIO 35). 
The preferences (animation, orientation) are saved in the flash memory after 5 minutes. 
Other settings (SSID, password, time zone) are immediately stored in flash memory.
================================================================================================================*/

#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>         // no need to download this library
#include <TFT_eSPI.h>            // Upload font files (i.e. C:\Windows\Fonts\name.ttf) from your PC to https://rop.nl/truetype2gfx/
#include "HPSimplified40pt7b.h"  // and put the result (name.h files) in the same folder as this sketch.
#include "HPSimplified20pt7b.h"  // https://espgo.be/freefont.html for more details

const char* timeZone[] = {
  // list of time zones: https://github.com/nayarsystems/posix_tz_db/blob/master/zones.csv
  "GMT0BST,M3.5.0/1,M10.5.0",       // UK, Ireland
  "WET0WEST,M3.5.0/1,M10.5.0",      // Portugal - Canarias - Faroe
  "CET-1CEST,M3.5.0,M10.5.0/3",     // Central Europe
  "EET-2EEST,M3.5.0/3,M10.5.0/4",   // Eastern Europa
  "MSK-3",                          // Moskow
  "GST-4",                          // Gulf Standard Time (VAE, Oman)
  "PKT-5",                          // Pakistan Standard Time
  "IST-5:30",                       // India Standard Time
  "BST-6",                          // Bangladesh Standard Time
  "ICT-7",                          // Indochina Time (Thailand, Vietnam, Cambodja, Laos)
  "CST-8",                          // China Standard Time (Peking, Shanghai)
  "JST-9",                          // Japan Standard Time
  "KST-9",                          // Korea Standard Time
  "AEST-10AEDT,M10.1.0,M4.1.0/3",   // Australia (Sydney, Melbourne)
  "SBT-11",                         // Solomon Islands Time
  "NZST-12NZDT,M9.5.0,M4.1.0/3",    // New-Zealand
  "MIT-13",                         // Tonga, Samoa
  "TOT-13",                         // Tonga Standard Time
  "WST-14",                         // Kiritimati (Line Islands, Kiribati)
  "HST10",                          // Hawaii-Aleutian Standard Time
  "AKST9AKDT,M3.2.0,M11.1.0/2",     // Alaska Time
  "PST8PDT,M3.2.0,M11.1.0/2",       // Pacific Time (Los Angeles, Vancouver)
  "MST7MDT,M3.2.0,M11.1.0/2",       // Mountain Time (Denver)
  "MST7",                           // Phoenix / Mazatlan / Whitehorse
  "CST6CDT,M3.2.0,M11.1.0/2",       // Central Time (Chicago, Mexico City)
  "EST5EDT,M3.2.0,M11.1.0/2",       // Eastern Time (New York, Toronto)
  "AST4ADT,M3.2.0,M11.1.0/2",       // Atlantic Time (Halifax)
  "BRT-3",                          // Brasília Time
  "ART-3",                          // Argentina Standard Time
  "NST-3:30NDT,M3.2.0,M11.1.0/2",   // Newfoundland Time
  "AZOT-1AZOST,M3.5.0/0,M10.5.0/1"  // Azores
};

#define FLAKE_SIZE 1        // size of the snowflakes
#define NUM_PARTICLES 1400  // number of fire particles
#define NUM_FLAKES 768      // number of snowflakes
#define NUM_DOTS 600        // number of dots in spiral animation
#define NUM_BUBBLES 64      // number of bubbles
#define BUBBLE_SIZE 6       // bubble size

struct Particle {   // struct for fire particles
  float x, y;       // position
  float vx, vy;     // speed
  uint8_t r, g, b;  // color
  float life;       // lifespan
  float decay;
};

struct Snowflake {
  int16_t x;
  uint16_t y;
  uint8_t z;  // depth
  int8_t w;   // horizontal velocity left / right
};

struct Bubble {
  int16_t x;
  uint16_t y;
  uint16_t w;
  uint8_t z;  // Depth (determines speed and size)
};

struct Gear {
  int16_t x, y;    // gear center
  int16_t radius;  // radius of the gear
  uint8_t teeth;   // number of teeth
  float angle;     // rotation angle in degrees
  float speed;     // rotation speed in degrees per frame
};

Particle particles[NUM_PARTICLES];
Snowflake flakes[NUM_FLAKES];
Bubble bubbles[NUM_BUBBLES];
Gear gears[] = {
  { 75, 70, 60, 36, 0, 1.0 },    // x-pos, y-pos, radius, number of gear teeth, angle, speed
  { 165, 95, 36, 24, 0, -1.5 },  // medium gear
  { 216, 80, 18, 16, 0, 2.25 }   // small gear
};

Preferences flash;
WebServer server(80);
TFT_eSPI tft = TFT_eSPI();
TFT_eSprite mySprite = TFT_eSprite(&tft);
TFT_eSprite landScape = TFT_eSprite(&tft);
TFT_eSprite hourSprite = TFT_eSprite(&tft);    // sprite for hour hand
TFT_eSprite minuteSprite = TFT_eSprite(&tft);  // minute hand
TFT_eSprite secondSprite = TFT_eSprite(&tft);  // second hand

float dotSize = 5, rotationAngle;  // dot size and angle for spiral pattern
const int clockRadius = 67, numGears = sizeof(gears) / sizeof(gears[0]);
uint16_t leftTop[70], leftBot[65];  // arrays to store the left column pixels (landscape animation)
bool showDay, showDate, wifiOk = false, settingsChanged = false, landscapeInit = false;
unsigned long lastButtonPress = 0, lastUpdate = 0, lastChangeTimestamp = 0;
const unsigned long interval = 40, saveDelay = 300000;  // delay for landscape animation ; 5 minutes "save to flash" delay
const float SPEED_FACTOR = 0.05, DECAY_FACTOR = 0.005;  // fire particles
struct tm tInfo;                                        // https://cplusplus.com/reference/ctime/tm/
uint8_t view;                                           // to save selection of animation
int tft_w, tft_h, rota;                                 // display width, height & oriëntation
uint8_t count = 0, TIME_TOP = 0;                        // distance between top of display and top of font HH:MM
uint8_t r, g, b;                                        // color fire particles
String webText, buttons, ssid, pasw;                    // used in html interface
char theDate[11];
const unsigned long debounceDelay = 300;  // UI debouncing buttons 300 millisec
// const char* weekdays[] = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" };
const char* weekdays[] = { "zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag" };

void setup() {
  pinMode(35, INPUT_PULLUP);  // button opposite flash button = flip screen
  pinMode(0, INPUT_PULLUP);   // flash button = change the animation
  readSettingsFromFlash();    // load from flash memory
  displayInit();
  tft.drawCentreString("Connecting to WiFi", 120, 60, 4);
  connect_to_WiFi();
  tft.fillScreen(TFT_BLACK);
  tft.drawCentreString("Obtaining time", 120, 60, 4);
  configTzTime(timeZone[count], "pool.ntp.org");
  randomSeed(millis());
  initBubbles();
}

void loop() {
  checkButtons();                                                        // check for button press
  if (settingsChanged && millis() - lastChangeTimestamp >= saveDelay) {  // if changes need to be saved: add a 5 minute delay
    writeSettingsToFlash();                                              // save changes
    settingsChanged = false;                                             // reset flag
  }
  switch (view) {
    case 0:  // fire animation
      TIME_TOP = 0;
      showDay = true;
      showDate = false;
      updateParticles();
      drawParticles();
      break;
    case 1:  // animation of spinning gears
      TIME_TOP = 0;
      showDay = true;
      showDate = false;
      drawBackgroundGears();
      updateGears();
      break;
    case 2:  // snow animation
      TIME_TOP = 20;
      showDay = true;
      showDate = false;
      snowAnim();
      break;
    case 3:  // animation of rotating spiral
      TIME_TOP = 20;
      showDay = true;
      showDate = false;
      updateSpiralPattern();
      break;
    case 4:  // animation of moving landscape
      TIME_TOP = 75;
      showDay = false;
      showDate = false;
      if (!landscapeInit) initLandscape();
      updateLandscape();
      break;
    case 5:  // animation of air bubbles in water
      TIME_TOP = 30;
      showDay = true;
      showDate = false;
      bubbleAnim();
      break;
    case 6:            // analog clock
      TIME_TOP = 136;  // 136 = out of range of display
      drawClockHands();
      showDay = false;
      showDate = false;
      break;
    case 7:  // no animation
      TIME_TOP = 0;
      mySprite.fillSprite(TFT_BLACK);
      showDay = true;
      showDate = true;
      break;
    default:  // no animation
      TIME_TOP = 0;
      mySprite.fillSprite(TFT_BLACK);
      showDay = true;
      showDate = true;
      break;
  }
  displayTime();
  mySprite.pushSprite(0, 0);  // write sprite to display
}

void displayInit() {
  tft.init();
  tft.setRotation(rota);
  tft.fillScreen(TFT_BLACK);
  tft.setTextColor(TFT_YELLOW);
  tft_w = tft.width();
  tft_h = tft.height();
  mySprite.createSprite(tft_w, tft_h);      // main sprite
  landScape.createSprite(tft_w, 65);        // trees landscape
  hourSprite.createSprite(clockRadius, 4);  // hour hand of analog clock
  hourSprite.fillSprite(TFT_BLACK);
  hourSprite.fillRect(0, 0, clockRadius / 2, 4, TFT_YELLOW);
  hourSprite.drawRect(0, 0, clockRadius / 2, 4, tft.color565(176, 176, 0));
  hourSprite.setPivot(0, 2);
  minuteSprite.createSprite(clockRadius, 4);
  minuteSprite.fillSprite(TFT_BLACK);
  minuteSprite.fillRect(0, 0, clockRadius / 7 * 6, 4, TFT_YELLOW);
  minuteSprite.drawRect(0, 0, clockRadius / 7 * 6, 4, tft.color565(176, 176, 0));
  minuteSprite.setPivot(0, 2);
  secondSprite.setColorDepth(8);
  secondSprite.createSprite(clockRadius, 2);
  secondSprite.fillSprite(TFT_BLACK);
  secondSprite.fillRect(0, 0, clockRadius, 2, TFT_RED);
  secondSprite.setPivot(0, 1);
}

void readSettingsFromFlash() {  // time zone, animation, display rotation
  flash.begin("my-clock", true);
  String savedTimeZone = flash.getString("timezone", timeZone[0]);  // default to first in list [0]
  for (int i = 0; i < sizeof(timeZone) / sizeof(timeZone[0]); i++) {
    if (savedTimeZone == String(timeZone[i])) {
      count = i;  // get time zone index
      break;
    }
  }
  view = flash.getInt("anim", 0);  // retrieve the last set animation - default to 0 (fire animation)
  rota = flash.getInt("rot", 3);   // retrieve the last set display rotation - default to 3 (usb left side)
  flash.end();                     // close prefs when ready
  view = view % 8;                 // modulo (# of elements in array) = prevent errors
}

void writeSettingsToFlash() {
  int tempView, tempRota;
  flash.begin("my-clock");                     // flash memory (also write since 2nd param = not set)
  tempView = flash.getInt("anim");             // retrieve the last set animation
  tempRota = flash.getInt("rot");              // retrieve the last set display rotation
  if (tempView != view || tempRota != rota) {  // only write to flash memory when it was changed
    flash.putInt("anim", view);                // to prevent chip wear from excessive writing
    flash.putInt("rot", rota);
  }
  flash.end();
}

void checkButtons() {
  if (!digitalRead(35) && millis() - lastButtonPress > debounceDelay) {  // rotate display
    rota = tft.getRotation() == 3 ? 1 : 3;
    tft.setRotation(rota);
    markSettingsChanged();
  }
  if (!digitalRead(0) && millis() - lastButtonPress > debounceDelay) {  // change animation
    view = (view + 1) % 8;
    markSettingsChanged();
  }
}

void markSettingsChanged() {
  lastButtonPress = millis();      // reset debounce timer
  settingsChanged = true;          // set flag
  lastChangeTimestamp = millis();  // set timestamp
  landscapeInit = false;
}

void updateParticles() {           // animation fire particles
  mySprite.fillSprite(TFT_BLACK);  // overwrite previous frame
  for (int i = 0; i < NUM_PARTICLES; i++) {
    Particle& p = particles[i];  // quicker access to struct
    p.x += p.vx;
    p.y += p.vy;
    p.life -= p.decay;
    if (p.life > 0.5) setColor(p, 255, random(150, 255), 0);
    else if (p.life > 0.2) setColor(p, random(200, 255), random(50, 150), 0);
    else setColor(p, random(100, 150), 0, 0);
    if (p.life <= 0 || p.y < 0) resetParticle(p);
  }
}

void setColor(Particle& p, uint8_t r, uint8_t g, uint8_t b) {
  p.r = r;
  p.g = g;
  p.b = b;
}

void resetParticle(Particle& p) {
  p.x = tft_w / 2 + random(-120, 120);
  p.y = tft_h;
  p.vx = random(-10, 10) * SPEED_FACTOR;
  p.vy = -random(15, 30) * SPEED_FACTOR;
  p.life = random(50, 100) / 100.0;
  p.decay = random(3, 7) * DECAY_FACTOR;
}

void drawParticles() {  // draw fire particles as colored pixels
  for (int i = 0; i < NUM_PARTICLES; i++) {
    mySprite.fillCircle(particles[i].x, particles[i].y, particles[i].life * 3,
                        mySprite.color565(particles[i].r, particles[i].g, particles[i].b));
  }
  mySprite.fillRect(0, 134, tft_w, 2, TFT_ORANGE);
}

void snowAnim() {
  mySprite.fillSprite(TFT_BLACK);
  for (int i = 0; i < NUM_FLAKES; ++i) {
    if (flakes[i].z <= 1) {
      flakes[i].x = random(tft_w);
      flakes[i].y = random(tft_h);
      flakes[i].w = (random(3) - 2) / 2.0;
      flakes[i].z = random(256);
    } else {
      flakes[i].y += random(2);
      flakes[i].x += flakes[i].w;
      if (flakes[i].y > tft_h) {
        flakes[i].y = 0;
        flakes[i].x = random(tft_w);
        flakes[i].w = (random(3) - 2) / 2.0;
      }
      if (flakes[i].x >= tft_w) flakes[i].x = 0;
      if (flakes[i].x < 0) flakes[i].x = tft_w - 1;
      if (random(5) == 0) flakes[i].w = (random(5) - 2) / 2.0;
      r = g = b = 255 - flakes[i].z;
      mySprite.fillCircle(flakes[i].x, flakes[i].y, FLAKE_SIZE, mySprite.color565(r, g, b));
    }
  }
}

void drawBackgroundGears() {
  mySprite.fillRect(0, 0, tft_w, tft_h, mySprite.color565(8, 8, 8));
  mySprite.drawRect(0, 0, tft_w, tft_h, mySprite.color565(32, 32, 32));
  int16_t screwX[] = { 8, 8, 232, 232 };  // coordinates of the screws
  int16_t screwY[] = { 8, 127, 8, 127 };
  int angles[] = { 60, 90, 30, 0 };  // each screw has a different orientation
  for (int i = 0; i < 4; i++) {      // draw the screws
    mySprite.fillCircle(screwX[i], screwY[i], 6, mySprite.color565(72, 72, 72));
    float angleRad = radians(angles[i]);             // calculate the line coordinates
    int16_t lineX1 = screwX[i] + cos(angleRad) * 4;  // starting point of the line
    int16_t lineY1 = screwY[i] + sin(angleRad) * 4;
    int16_t lineX2 = screwX[i] - cos(angleRad) * 4;  // end point of the line
    int16_t lineY2 = screwY[i] - sin(angleRad) * 4;
    mySprite.drawLine(lineX1, lineY1, lineX2, lineY2, TFT_BLACK);  // draw the line
  }
}

void drawGear(Gear& gear) {
  const float angleStep = 360.0 / gear.teeth;  // angle step per tooth
  const float toothWidthRatio = 0.35;          // ratio for the width of the tooth
  const float innerRadiusRatio = 0.9;          // ratio for inner radius of tooth
  for (int i = 0; i < gear.teeth; i++) {
    float startAngle = radians(gear.angle + i * angleStep);
    float midAngle = radians(gear.angle + (i + 0.5) * angleStep);  // center of the tooth
    float endAngle = radians(gear.angle + (i + 1) * angleStep);
    int16_t outerX1 = gear.x + cos(startAngle) * (gear.radius * innerRadiusRatio);  // calculate outer points of the tooth
    int16_t outerY1 = gear.y + sin(startAngle) * (gear.radius * innerRadiusRatio);
    int16_t outerX2 = gear.x + cos(endAngle) * (gear.radius * innerRadiusRatio);
    int16_t outerY2 = gear.y + sin(endAngle) * (gear.radius * innerRadiusRatio);
    int16_t outerTipX = gear.x + cos(midAngle) * gear.radius;
    int16_t outerTipY = gear.y + sin(midAngle) * gear.radius;
    mySprite.fillTriangle(outerX1, outerY1, outerX2, outerY2, outerTipX, outerTipY, mySprite.color565(96, 96, 96));  // draw  tooth
  }
  mySprite.fillCircle(gear.x, gear.y, gear.radius * innerRadiusRatio, mySprite.color565(128, 128, 128));  // inner parts of the gear
  mySprite.fillCircle(gear.x, gear.y, (gear.radius * innerRadiusRatio) - 3, mySprite.color565(64, 64, 64));
  mySprite.fillCircle(gear.x, gear.y, 4, mySprite.color565(128, 128, 128));
  const float holeRadius = gear.radius * innerRadiusRatio * 0.38;  // draw holes in the gear: Radius of cavities
  const float holeOffset = gear.radius * innerRadiusRatio * 0.55;  // distance from center to hole
  for (int i = 0; i < 4; i++) {
    float holeAngle = radians(90 * i + gear.angle);  // each 90 degrees
    int16_t holeX = gear.x + cos(holeAngle) * holeOffset;
    int16_t holeY = gear.y + sin(holeAngle) * holeOffset;
    mySprite.fillCircle(holeX, holeY, holeRadius, TFT_BLACK);
    mySprite.drawCircle(holeX, holeY, holeRadius, mySprite.color565(128, 128, 128));
    mySprite.drawCircle(holeX, holeY, holeRadius - 1, mySprite.color565(128, 128, 128));
  }
}

void updateGears() {
  for (int i = 0; i < numGears; i++) {
    gears[i].angle += gears[i].speed;
    if (gears[i].angle >= 360) gears[i].angle -= 360;
    if (gears[i].angle < 0) gears[i].angle += 360;
    drawGear(gears[i]);
  }
}

void drawSpiralPattern(int centerX, int centerY, float dotSize, float rotationAngle) {
  const float goldenAngle = 137.5;  // golden angle in degrees
  uint16_t colors[] = { TFT_RED, TFT_ORANGE, TFT_YELLOW, TFT_GREEN, TFT_CYAN, TFT_BLUE, TFT_PURPLE };
  int numColors = sizeof(colors) / sizeof(colors[0]);
  for (int i = 0; i < NUM_DOTS; i++) {
    float angle = i * goldenAngle * DEG_TO_RAD + rotationAngle;  // add rotation angle
    float radius = sqrt(i) * dotSize;
    int x = centerX + cos(angle) * radius;
    int y = centerY + sin(angle) * radius;
    uint16_t color = colors[i % numColors];
    mySprite.drawCircle(x, y, dotSize / 2, color);
  }
}

void updateSpiralPattern() {
  mySprite.fillSprite(TFT_BLACK);
  rotationAngle -= 0.01;  // slow rotation (anticlockwise)
  drawSpiralPattern(120, 67, dotSize, rotationAngle);
}

void initLandscape() {
  mySprite.fillRectVGradient(0, 0, tft_w, 70, TFT_NAVY, TFT_SKYBLUE);  // draw the sky
  int hillXPos[] = { 40, 120, -40, 200, 0, 80, 160 };                  // hills: x-coordinates
  for (int i = 0; i < 7; i++) {                                        // draw hills in random green values
    mySprite.fillTriangle(hillXPos[i], 70, hillXPos[i] + 80, 70, hillXPos[i] + 40, 40, tft.color565(0, random(64, 108), 0));
  }
  int treeXpos[] = { 15, 30, 50, 80, 95, 110, 130, 140, 155, 170, 190, 207, 231 };  // position data for trees
  int numTrees = sizeof(treeXpos) / sizeof(treeXpos[0]);
  for (int i = 0; i < numTrees; i++) {  // draw trees, random trunk & foliage height and random green value foliage
    int x = treeXpos[i];
    drawTree(x, 71, tft.color565(120, 60, 0), tft.color565(0, random(64, 145), 0), random(9, 16), random(34, 52));
  }
  landScape.fillRectVGradient(0, 0, tft_w, 65, tft.color565(72, 72, 0), TFT_BROWN);
  for (int count = 0; count < 5000; count++) {  // fill foreground landscape with random short vertical lines
    uint8_t x = random(tft_w);
    uint8_t y = random(65);
    landScape.drawLine(x, y, x, y + 3, tft.color565(0, random(90 - y, (90 - y) * 2), 0));
  }
  landscapeInit = true;
}

void drawTree(uint8_t left, uint8_t top, int trunkColor, int folCol, uint8_t folBot, uint8_t folTop) {
  uint8_t trunkHeight = max(16, static_cast<int>(folBot));
  mySprite.fillRect(left, top - trunkHeight, 5, trunkHeight, trunkColor);
  uint8_t centerX = left + 2;                                     // center of ellips X
  uint8_t centerY = (((top - folBot) + (top - folTop)) / 2) - 3;  // center of ellips Y
  uint8_t radiusX = 6;                                            // horizontal radius ellips
  uint8_t radiusY = folTop / 2;                                   // vertical radius ellips
  mySprite.fillEllipse(centerX, centerY, radiusX, radiusY, folCol);
}

void updateLandscape() {
  unsigned long currentTime = millis();
  if (currentTime - lastUpdate >= interval) {
    lastUpdate = currentTime;
    for (int y = 0; y < 65; y++) leftBot[y] = landScape.readPixel(0, y);                    // read the row of pixels on the left
    landScape.scroll(-1, 0);                                                                // slide the landscape to the left
    for (int y = 0; y < 65; y++) landScape.drawPixel(mySprite.width() - 1, y, leftBot[y]);  // write the row of pixels on the right
    landScape.pushToSprite(&mySprite, 0, 70);                                               // add the bottom sprite to the main sprite
    for (int y = 0; y < 70; y++) leftTop[y] = mySprite.readPixel(0, y);                     // repeat the sliding for the upper part
    mySprite.scroll(-1, 0);
    for (int y = 0; y < 70; y++) mySprite.drawPixel(mySprite.width() - 1, y, leftTop[y]);
  }
}

void initBubbles() {
  for (int i = 0; i < NUM_BUBBLES; ++i) {
    bubbles[i].x = random(tft_w);  // random horizontal position
    bubbles[i].y = random(tft_h);  // random vertical position
    bubbles[i].z = random(256);    // random depth
  }
}

void bubbleAnim() {
  mySprite.fillRectVGradient(0, 0, tft_w, tft_h, TFT_BLUE, TFT_NAVY);
  for (int i = 0; i < NUM_BUBBLES; ++i) {
    if (bubbles[i].z <= 1) {                           // check if the bubbles need to be reset
      bubbles[i].x = random(tft_w);                    // random horizontal position
      bubbles[i].y = tft_h + random(BUBBLE_SIZE, 30);  // start below the display
      bubbles[i].w = (random(3) - 1) / 2.0;            // random horizontal speed (-0.5 to 0.5)
      bubbles[i].z = random(64, 256);                  // random depth
    } else {
      float depthFactor = (256.0 - bubbles[i].z) / 256.0;  // depth factor
      float riseSpeed = depthFactor * 2.0;                 // vertical speed dependent on depth
      bubbles[i].y -= riseSpeed;                           // move the bubble up
      bubbles[i].x += bubbles[i].w;
      if (random(20) == 0) bubbles[i].w = (random(5) - 2) / 2.0;                                    // randomly adjust horizontal speed
      if (bubbles[i].y < 1 || bubbles[i].x < -BUBBLE_SIZE || bubbles[i].x > tft_w + BUBBLE_SIZE) {  // reset bubble if out of range
        bubbles[i].x = random(tft_w);
        bubbles[i].y = tft_h + random(BUBBLE_SIZE, 30);
        bubbles[i].w = (random(3) - 1) / 2.0;
        bubbles[i].z = random(50, 256);
      }
    }
    float positionFactor = (float)bubbles[i].y / tft_h;  // relative position
    int bubbleSize = BUBBLE_SIZE + (1.0 - positionFactor) * (BUBBLE_SIZE / 2);
    bubbleSize = constrain(bubbleSize, 1, BUBBLE_SIZE + 4);
    uint8_t brightness = 255 - bubbles[i].z;  // brightness dependent on depth
    brightness = constrain(brightness, 50, 255);
    uint16_t bubbleColor = mySprite.color565(brightness, brightness, 255);         // light blue
    mySprite.drawCircle(bubbles[i].x, bubbles[i].y, bubbleSize, bubbleColor);      // draw bubble
    mySprite.drawCircle(bubbles[i].x, bubbles[i].y, bubbleSize - 1, bubbleColor);  // draw bubble
  }
}

void drawClockFace() {
  float lightAngle = 270.0 * DEG_TO_RAD;                             // light coming from above
  int outerRadiusStart = clockRadius + 5;                            // start radius of clock case
  int outerRadiusEnd = clockRadius + 13;                             // end radius of clock case
  for (float r = outerRadiusStart; r <= outerRadiusEnd; r += 0.5) {  // small steps = fine texture
    for (float angle = 0; angle < 360; angle += 0.2) {
      float radian = angle * DEG_TO_RAD;
      int x = tft_w / 2 + cos(radian) * r;
      int y = tft_h / 2 + sin(radian) * r;
      float angleDifference = cos(radian - lightAngle);     // shadow intensity based on light angle
      uint8_t grayValue = 160 + (angleDifference * 95);     // base gray with variation (-95 to +95)
      if (angleDifference > 0.9 && r > clockRadius + 10) {  // stronger highlight for specific angles and radius
        grayValue += 40;                                    // brighten the area
        if (grayValue > 255) grayValue = 255;               // clamp value to avoid overflow
      }
      uint16_t color = tft.color565(grayValue, grayValue, grayValue);
      mySprite.drawPixel(x, y, color);
    }
  }
  int knobWidth = 12;  // rectangular knob with ridges
  int knobHeight = 30;
  int knobCenterX = tft_w / 2 + clockRadius + 20;                                     // to the right of the clock case
  int knobTopY = tft_h / 2 - knobHeight / 2;                                          // vertically centered
  for (int x = knobCenterX - knobWidth / 2; x <= knobCenterX + knobWidth / 2; x++) {  // drawing the knob with ribbing
    for (int y = knobTopY; y <= knobTopY + knobHeight; y++) {
      float normalizedX = (float)(x - (knobCenterX - knobWidth / 2)) / knobWidth;  // calculate relative position
      float normalizedY = (float)(y - knobTopY) / knobHeight;
      float lightEffect = normalizedX * cos(lightAngle) + normalizedY * sin(lightAngle);  // gradient for metal look
      uint8_t grayValue = 160 + lightEffect * 80;
      uint16_t color = tft.color565(grayValue, grayValue, grayValue);
      if ((x - (knobCenterX - knobWidth / 2)) % 4 == 0) color = tft.color565(grayValue - 30, grayValue - 30, grayValue - 30);
      mySprite.drawPixel(x, y, color);
    }
  }
  mySprite.drawCircle(tft_w / 2, tft_h / 2, clockRadius, TFT_WHITE);  // drawing the clock's edge
  for (int i = 0; i < 60; i++) {                                      // markings on the dial
    float angle = i * 6.0 * DEG_TO_RAD;                               // 6 degrees per line
    int xOuter = tft_w / 2 + cos(angle) * clockRadius;
    int yOuter = tft_h / 2 + sin(angle) * clockRadius;
    int markLength = (i % 5 == 0) ? 14 : 5;  // long stripes for hours
    int xInner = tft_w / 2 + cos(angle) * (clockRadius - markLength);
    int yInner = tft_h / 2 + sin(angle) * (clockRadius - markLength);
    mySprite.drawLine(xOuter, yOuter, xInner, yInner, TFT_WHITE);
  }
}

void drawClockHands() {
  const uint16_t colors[2][2] = {
    { tft.color565(64, 0, 0), tft.color565(128, 0, 0) },
    { tft.color565(128, 0, 0), tft.color565(64, 0, 0) }
  };  // fill the screen with thin color gradients
  for (int i = 0; i < 34; i++) mySprite.fillRectVGradient(0, i * 4, tft_w, 14, colors[i % 2][0], colors[i % 2][1]);
  mySprite.fillCircle(tft_w / 2, tft_h / 2, clockRadius + 15, tft.color565(96, 96, 96));
  mySprite.fillCircle(tft_w / 2, tft_h / 2, clockRadius + 4, TFT_BLACK);
  for (int y = -clockRadius; y <= clockRadius; y++) {  // fill dial with blue color gradient
    for (int x = -clockRadius; x <= clockRadius; x++) {
      if (x * x + y * y <= clockRadius * clockRadius) {  // check if the point is inside the circle
        uint8_t blueValue = (clockRadius - (y + clockRadius)) * 112 / (2 * clockRadius);
        uint16_t color = tft.color565(0, 0, blueValue + 100);
        mySprite.drawPixel(tft_w / 2 + x, tft_h / 2 + y, color);
      }
    }
  }
  mySprite.setTextColor(TFT_DARKGREY);
  mySprite.setTextFont(1);
  mySprite.drawCentreString("Lilygo", tft_w / 2, 30, 1);  // "brand" of the watch
  mySprite.drawCentreString("T-Display", tft_w / 2, 40, 1);
  drawClockFace();
  mySprite.setTextColor(TFT_YELLOW);
  mySprite.fillRect((tft_w / 2) - 10, 102, 19, 13, TFT_NAVY);  // box for date (day of the month)
  mySprite.drawRect((tft_w / 2) - 11, 101, 21, 15, TFT_DARKGREY);
  mySprite.drawCentreString(String(tInfo.tm_mday), tft_w / 2, 101, 2);  // day of the month
  int hour = tInfo.tm_hour;
  int minute = tInfo.tm_min;
  int second = tInfo.tm_sec;
  float hourAngle = ((hour % 12 + minute / 60.0) * 30.0 + 270);  // 30 degrees per hour
  float minuteAngle = (minute + second / 60.0) * 6.0 + 270;      // 6 degrees per minute
  float secondAngle = second * 6.0 + 270;
  hourSprite.pushRotated(&mySprite, hourAngle, TFT_BLACK);  // draw the hands
  minuteSprite.pushRotated(&mySprite, minuteAngle, TFT_BLACK);
  secondSprite.pushRotated(&mySprite, secondAngle, TFT_BLACK);
  mySprite.fillCircle(tft_w / 2, tft_h / 2, 3, TFT_RED);  // central axis of the hands
  mySprite.fillCircle(tft_w / 2, tft_h / 2, 1, TFT_LIGHTGREY);
}

void displayTime() {     // digital time representation
  getLocalTime(&tInfo);  // SNTP update every 3 hours (default ESP32) since we did not set an interval
  mySprite.setTextColor(TFT_CYAN, TFT_BLACK);
  mySprite.setFreeFont(&HPSimplified40pt7b);  // this font file can be created & downloaded at https://rop.nl/truetype2gfx/
  mySprite.setCursor(2, 54 + TIME_TOP);
  mySprite.printf("%02d:%02d", tInfo.tm_hour, tInfo.tm_min);
  mySprite.setCursor(198, 54 + TIME_TOP);
  mySprite.setFreeFont(&HPSimplified20pt7b);
  mySprite.printf("%02d", tInfo.tm_sec);
  mySprite.setTextColor(TFT_ORANGE);
  if (showDay) mySprite.drawCentreString(weekdays[tInfo.tm_wday], 120, 64 + TIME_TOP, 1);  // sunray animation
  if (showDate) {
    strftime(theDate, sizeof(theDate), "%d-%m-%G", &tInfo);  // https://cplusplus.com/reference/ctime/strftime
    mySprite.setTextColor(TFT_GREEN);
    mySprite.drawCentreString(theDate, 120, 104, 1);
  }
}

void showMessageNoConnection() {  // message on display when there is no WiFi connection
  const char* noConnec[] = { "WiFi: no connection.", "Connect to hotspot", "'Lily', open browser",
                             "address 192.168.4.1", "for login + password." };
  tft.fillScreen(TFT_NAVY);
  tft.setTextColor(TFT_YELLOW);
  for (uint8_t i = 0; i < 5; i++) tft.drawCentreString(noConnec[i], 120, i * 27, 4);
}

void connect_to_WiFi() {
  WiFi.mode(WIFI_MODE_STA);
  flash.begin("login_data", true);
  ssid = flash.getString("ssid", "");
  pasw = flash.getString("pasw", "");
  flash.end();
  WiFi.begin(ssid.c_str(), pasw.c_str());
  for (uint8_t i = 0; i < 50; ++i) {
    if (WiFi.isConnected()) {
      WiFi.setAutoReconnect(true);
      WiFi.persistent(true);
      return;
    }
    delay(160);
  }
  showMessageNoConnection();
  WiFi.disconnect(true);
  delay(100);
  WiFi.scanDelete();
  delay(100);
  int n = WiFi.scanNetworks();
  buttons = "";  // empty old data
  if (n == 0) buttons = "<p>No networks found</p>";
  else {
    for (int i = 0; i < n; ++i) {  // html to display found networks on buttons
      buttons += "\n<button onclick='scrollNaar(this.id)' id='" + WiFi.SSID(i) + "'>" + WiFi.SSID(i) + "</button><br>";
    }
  }
  WiFi.mode(WIFI_MODE_AP);
  WiFi.softAP("Lily", "", 1);
  server.on("/", server_Root);
  server.on("/setting", server_Setting);
  server.begin();
  for (;;) server.handleClient();
}

void server_Root() {
  const char* timezones[] = {
    "London (GMT/BST)", "Portugal / Canarias (WET/WEST)", "Brussels / Berlin(CET/CEST)", "Athens (EET/EEST)", "Moscow (MSK)",
    "Dubai (GST)", "Karachi (PKT)", "Delhi (IST)", "Dhaka (BST)", "Bangkok (ICT)", "Shanghai (CST)",
    "Tokyo (JST)", "Seoul (KST)", "Sydney (AEST/AEDT)", "Honiara (SBT)", "Auckland (NZST/NZDT)",
    "Apia (MIT)", "Nuku'alofa (TOT)", "Kiritimati (WST)", "Honolulu (HST)", "Anchorage (AKST/AKDT)",
    "Los Angeles (PST/PDT)", "Denver (MST/MDT)", "Phoenix (MST)", "Mexico City (CST/CDT)", "New York (EST/EDT)",
    "Halifax (AST/ADT)", "São Paulo (BRT)", "Buenos Aires (ART)", "St. John's (NST/NDT)", "Ponta Delgada (AZOT/AZOST)"
  };
  webText = "<!DOCTYPE HTML>\n<html lang='en'>\n<head><title>Setup</title>\n<meta name='viewport' ";
  webText += "content='width=device-width, initial-scale=1.0'>\n<style>\np {\n  font-family: Arial, Helvetica, sans-serif;\n";
  webText += "  font-size: 18px;\n  margin: 0;\n  text-align: center;\n}\n\n";
  webText += "button, input[type=submit] {\n  width: 250px;\n  border-radius: 5px;\n  color: White;\n  padding: 4px 4px;\n";
  webText += "  margin-top: 16px;\n  margin: 0 auto;\n  display:block;\n  font-size: 18px;\n  font-weight: 600;\n  ";
  webText += "background: DodgerBlue;\n}\n\n";
  webText += "input, select {\n  width: 250px;\n  font-size: 18px;\n  font-weight: 600;\n}\n</style>\n</head>\n<body>";
  webText += "<p style='font-family:arial; font-size:240%;'>WiFi setup\n</p>";
  webText += "<p style='font-family:arial; font-size:160%;'><br>Click on item to select or<br>Enter your network data<br>";
  webText += "and time zone<br>in the boxes below. <br>Networks found: </p><br>";
  webText += buttons;
  webText += "\n<form method='get' action='setting'>\n<p><b>\nSSID: <br>\n<input id='ssid' name='ssid'>";
  webText += "<br>PASW: </b><br>\n<input type='password' name='pass'><br><br>";
  webText += "Time Zone: <br>\n<select name='timezone'>";
  for (int i = 0; i < sizeof(timeZone) / sizeof(timeZone[0]); i++) {
    webText += "\n<option value='" + String(i) + "'";
    if (i == count) webText += " selected";
    webText += ">" + String(timezones[i]) + "</option>";
  }
  webText += "\n</select><br><br>";
  webText += "<input type='submit' value='Save'>";
  webText += "\n</p>\n</form>\n<script>\nfunction scrollNaar(tekst) {\n  document.getElementById('ssid').value = tekst;\n";
  webText += "  window.scrollTo(0, document.body.scrollHeight);\n}\n</script>\n</body>\n</html>";
  server.send(200, "text/html", webText);
}

void server_Setting() {
  String myssid = server.arg("ssid");
  String mypasw = server.arg("pass");
  String mytimezone = server.arg("timezone");
  if (myssid.length() > 0) {
    flash.begin("login_data", false);
    flash.putString("ssid", myssid);
    flash.putString("pasw", mypasw);
    flash.end();
  }
  if (mytimezone.length() > 0) {
    flash.begin("my-clock", false);
    flash.putString("timezone", timeZone[mytimezone.toInt()]);  // Sla de tijdzone-string op
    flash.end();
  }
  webText = "<!DOCTYPE HTML>\n<html lang='en'>\n<head><title>Setup</title>\n<meta name='viewport' ";
  webText += "content='width=device-width, initial-scale=1.0'>\n<style>\n* {\n  font-family: Arial, Helvetica";
  webText += ", sans-serif;\n  font-size: 45px;\n  font-weight: 600;\n  margin: 0;\n  text-align: center;\n}";
  webText += "\n\n@keyframes op_en_neer {\n  0%   {height:  0px;}\n  50%  {height:  40px;}\n  100% {height:  0px;}\n}";
  webText += "\n\n.opneer {\n  margin: auto;\n  text-align: center;\n  animation: op_en_neer 2s infinite;\n}";
  webText += "\n</style>\n</head>\n<body>\n<div class=\"opneer\"></div>\nESP will reboot<br>Close this window";
  webText += "\n</body>\n</html>";
  server.send(200, "\ntext/html", webText);
  delay(500);
  ESP.restart();
}


Page featuring more displays and sketches