// ======== sketch.ino(完整:新增「最後一張 → 主畫面」專屬賽博龐克轉場) ========
#include <stdint.h>
#include <math.h>
#include <stdio.h>
// ===== 型別與表情資料(置頂,避免 Arduino 自動 prototype 亂序) =====
struct Geom { int eyeW=20, eyeH=26, eyeR=6, eyeGap=28, eyeCy=30; };
struct Eye { int cx, cy; };
struct Expr {
float open, align; bool rounded; int8_t yOff; float hScale;
float alignDeltaL, alignDeltaR; int8_t yOffDeltaL, yOffDeltaR;
bool useCut; uint8_t cutInner, cutOuter;
};
enum EID : uint8_t {
Neutral, BlinkHigh, BlinkLow, Bored, Sleepy,
Focused, Annoyed, Surprised, Happy, Skeptic, Angry, EMO_COUNT
};
const char* EMO_NAME[EMO_COUNT] = {
"Neutral","BlinkHigh","BlinkLow","Bored","Sleepy",
"Focused","Annoyed","Surprised","Happy","Skeptic","Angry"
};
Expr PRESET[EMO_COUNT] = {
/* Neutral */ {1.00f,0.50f,true , 0,1.00f, 0,0, 0,0, false,0,0},
/* BlinkHigh */ {0.10f,0.25f,false,0,1.00f, 0,0, 0,0, false,0,0},
/* BlinkLow */ {0.10f,0.50f,false,0,1.00f, 0,0, 0,0, false,0,0},
/* Bored */ {0.40f,0.50f,false,0,1.00f, 0,0, 0,0, false,0,0},
/* Sleepy */ {0.22f,0.60f,false,+2,1.00f, 0,0, 0,0, false,0,0},
/* Focused */ {0.55f,0.45f,false,-1,1.00f, 0,0, 0,0, false,0,0},
/* Annoyed */ {0.35f,0.40f,false,-2,1.00f, 0,0, 0,0, false,0,0},
/* Surprised */ {1.00f,0.50f,true ,-1,1.10f, 0,0, 0,0, false,0,0},
/* Happy */ {0.90f,0.55f,true , 0,1.00f, 0,0, 0,0, false,0,0},
/* Skeptic */ {0.38f,0.38f,false,-1,1.00f, -0.18f,+0.18f, -1,+1, false,0,0},
/* Angry */ {1.00f,0.45f,true ,-1,1.00f, 0,0, 0,0, true, 12, 0}
};
float EMO_WEIGHT[EMO_COUNT] = { 1, 0.35f,0.35f,0.8f,0.7f,0.9f,0.7f,0.6f,0.8f,0.5f,0.5f };
bool EMO_ENABLE[EMO_COUNT] = {
true,true,true,true,true,true,true,true,true,true,true
};
// ===== Arduino / U8g2 =====
#include <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <esp_system.h> // esp_random()
/* 硬體 */
#define I2C_SDA 4
#define I2C_SCL 5
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /*reset=*/U8X8_PIN_NONE);
// ---------- 連到 Image.c ----------
extern "C" {
extern uint16_t IMG_W; // 建議 128
extern uint16_t IMG_H; // 建議 60
extern const uint8_t IMG1[];
extern const uint8_t IMG2[];
extern const uint8_t IMG3[];
extern const uint8_t IMG4[];
extern const uint8_t IMG5[];
extern const uint8_t IMG6[];
}
const uint8_t* const IMGS[6] = { IMG1, IMG2, IMG3, IMG4, IMG5, IMG6 };
// ---------- 輪播/轉場參數 ----------
uint32_t IMG_DUR_MS[6] = { 1200, 1200, 1200, 1200, 1200, 1200 }; // 每張顯示時間
#define TRANS_DUR_MS 550 // 一般轉場(快門)時間
#define IDLE_IMG_INDEX 3
#define EYES_INTERVAL_MIN_MS 10000
#define EYES_INTERVAL_MAX_MS 20000
#define FULL_CYCLE_MS (3UL * 60UL * 1000UL)
// ⭐ 第六張(最後一張)相關
#define LAST_IMG_HOLD_MS 2500 // 第 6 張停留多久
#define LAST_TO_IDLE_DUR_MS 750 // 第 6 張 → 主畫面 新轉場動畫時間
// ---------- 工具 ----------
static inline uint32_t urand(uint32_t a, uint32_t b){ return a + (esp_random() % (b - a + 1)); }
// ---------- Bitmap helpers ----------
static inline uint8_t rev8(uint8_t b){
b = (b & 0xF0) >> 4 | (b & 0x0F) << 4;
b = (b & 0xCC) >> 2 | (b & 0x33) << 2;
b = (b & 0xAA) >> 1 | (b & 0x55) << 1;
return b;
}
void drawBitmapFromArray(int x0, int y0, int w, int h, const uint8_t *data,
bool msb_first = true, bool invert = false){
int bpr = w / 8;
for(int y = 0; y < h; ++y){
const uint8_t* row = data + y * bpr;
for(int bx = 0; bx < bpr; ++bx){
uint8_t v = row[bx];
if(msb_first) v = rev8(v);
for(int bit = 0; bit < 8; ++bit){
bool on = (v >> bit) & 1;
if(invert) on = !on;
int px = x0 + bx*8 + bit;
int py = y0 + y;
if(on && px>=0 && px<OLED_WIDTH && py>=0 && py<OLED_HEIGHT) u8g2.drawPixel(px, py);
}
}
}
}
void drawImageCentered(uint8_t idx, bool invert=false){
int x = (OLED_WIDTH - (int)IMG_W) / 2;
int y = (OLED_HEIGHT - (int)IMG_H) / 2;
drawBitmapFromArray(x, y, IMG_W, IMG_H, IMGS[idx], true, invert);
}
// ---------- 一般賽博龐克轉場(快門 + 雜訊) ----------
static void overlayScanlines(int step){
for(int y=0;y<OLED_HEIGHT;y+=step) u8g2.drawHLine(0,y,OLED_WIDTH);
}
static void overlayRandomBars(int count){
u8g2.setDrawColor(0);
for(int i=0;i<count;i++){
int y = esp_random() % OLED_HEIGHT;
int h = 1 + (esp_random()%3);
u8g2.drawBox(0,y,OLED_WIDTH,h);
}
if (esp_random()%4==0){
int x = esp_random()%OLED_WIDTH;
int w = 2 + (esp_random()%6);
u8g2.drawBox(x,0,w,OLED_HEIGHT);
}
u8g2.setDrawColor(1);
}
static void overlayShutter(float p){
int h = (int)(p * (OLED_HEIGHT/2.0f));
u8g2.setDrawColor(0);
if(h>0){
u8g2.drawBox(0,0,OLED_WIDTH,h);
u8g2.drawBox(0,OLED_HEIGHT-h,OLED_WIDTH,h);
}
u8g2.setDrawColor(1);
}
void drawCyberpunkTransition(uint8_t fromIdx, uint8_t toIdx, float p){
u8g2.clearBuffer();
if (p < 0.5f){
drawImageCentered(fromIdx);
overlayShutter(p*2.0f);
}else{
drawImageCentered(toIdx);
overlayShutter((1.0f - p)*2.0f);
}
int step = 2 + (int)(p*6.0f);
overlayScanlines(step);
overlayRandomBars(1 + (int)(p*8.0f));
if ((esp_random()%100) < (int)(p*40)){
u8g2.setFont(u8g2_font_5x7_tf);
char buf[20]; sprintf(buf,"SYS %02X", (unsigned)(esp_random()%256));
u8g2.drawStr(2, OLED_HEIGHT-2, buf);
}
u8g2.sendBuffer();
}
/* ---------- 新:最後一張 → 主畫面 專屬轉場(切片失真 + 百葉窗揭幕) ---------- */
// 1) 切片失真:逐行位移 + 隨機反相/丟行(p ∈ [0,1],前半段使用)
static void drawImageGlitchSlices(uint8_t idx, float f){
int x0 = (OLED_WIDTH - (int)IMG_W) / 2;
int y0 = (OLED_HEIGHT - (int)IMG_H) / 2;
int bpr = IMG_W / 8;
for(int y=0; y<IMG_H; ++y){
const uint8_t* row = IMGS[idx] + y*bpr;
// 位移量:跟行號相關,幅度隨 f 放大
float s = sinf(y*0.22f) + sinf(y*0.07f);
int jitter = ((y%7)==0 && (esp_random()%3==0)) ? (int)(2*f) : 0;
int xoff = (int)(s * 5.0f * f) + jitter * ((y%2)?1:-1);
bool invertLine = (esp_random()%120) < (int)(f*100); // f 越大越常反相
for(int bx=0; bx<bpr; ++bx){
uint8_t v = rev8(row[bx]);
if(invertLine) v = ~v;
for(int bit=0; bit<8; ++bit){
if((v>>bit)&1){
int px = x0 + xoff + bx*8 + bit;
int py = y0 + y;
if(px>=0 && px<OLED_WIDTH && py>=0 && py<OLED_HEIGHT) u8g2.drawPixel(px, py);
}
}
}
// 偶發丟行(黑條)
if ((esp_random()%260) < (int)(f*120)){
u8g2.setDrawColor(0);
u8g2.drawHLine(0, y0+y, OLED_WIDTH);
u8g2.setDrawColor(1);
}
}
}
// 2) 百葉窗揭幕:在主畫面上覆蓋可伸縮的直條黑幕(p ∈ [0,1],後半段使用)
static void overlayVenetianReveal(float f){
int period = 8; // 每組百葉窗寬度
int gap = max(1, (int)(period * f)); // 窗縫寬度,隨 f 增
for(int x=0; x<OLED_WIDTH; x+=period){
int cover = period - gap; // 要遮住的寬度
if (cover <= 0) continue;
// 做一點「斜切感」,讓每片葉子上下錯位
int skew = (x/period)%4; // 0..3
int top = 0 + skew; // 往下偏一點
int h = OLED_HEIGHT - skew*2;
u8g2.setDrawColor(0);
u8g2.drawBox(x, top, cover, h);
u8g2.setDrawColor(1);
}
// 附帶一點 HUD 文字與雜訊
if ((esp_random()%3)==0){
u8g2.setFont(u8g2_font_5x7_tf);
char buf[24]; sprintf(buf,"LINK %02X.%02X", (unsigned)(esp_random()%256), (unsigned)(esp_random()%256));
u8g2.drawStr(2, 8, buf);
}
// 細掃描線
for(int y=0; y<OLED_HEIGHT; y+=2) u8g2.drawHLine(0,y,OLED_WIDTH);
}
// 主函式:p ∈ [0,1]
void drawLastToIdleTransition(uint8_t lastIdx, uint8_t idleIdx, float p){
u8g2.clearBuffer();
if (p < 0.5f){
float f = p * 2.0f; // 0..1
drawImageGlitchSlices(lastIdx, f); // 切片失真
// 偶發條碼噪聲
if ((esp_random()%5)==0){
u8g2.setDrawColor(0);
int x = esp_random()%OLED_WIDTH, w = 2 + (esp_random()%7);
u8g2.drawBox(x,0,w,OLED_HEIGHT);
u8g2.setDrawColor(1);
}
}else{
float f = (p - 0.5f) * 2.0f; // 0..1
drawImageCentered(idleIdx); // 背後是主畫面
overlayVenetianReveal(f); // 百葉窗揭幕
}
u8g2.sendBuffer();
}
/* ======================= 眼睛系統(隨機+可開關) ======================= */
Geom G;
Eye L, R;
#define FPS 30
// 眨眼(一般)
#define BLINK_MIN_MS 2500
#define BLINK_MAX_MS 5000
#define BLINK_DUR_MS 120
#define LONG_BLINK_CHANCE 12
#define LONG_BLINK_EXTRA 150
// Angry 專屬
#define BLINK_MIN_MS_ANGRY 1200
#define BLINK_MAX_MS_ANGRY 2200
#define BLINK_DUR_MS_ANGRY 80
// 凝視/微掃視
#define GAZE_LIMIT_X 10
#define GAZE_LIMIT_Y 4
#define FIXATION_MS_MIN 600
#define FIXATION_MS_MAX 1200
#define SACCADE_JUMP_X 4
#define SACCADE_JUMP_Y 2
// 緩動
#define SPRING_K 0.18f
#define SPRING_DAMP 0.75f
// 偶發水平掃視
#define SCAN_INTERVAL_MIN 8000
#define SCAN_INTERVAL_MAX 15000
#define SCAN_SWEEP_PIX 6
#define SCAN_SWEEP_MS 1300
// 表情輪播
#define EMO_MIN_MS 3500
#define EMO_MAX_MS 7000
#define START_HOLD_MS 3000
// 狀態(眼睛)
unsigned long lastFrame=0;
unsigned long nextBlinkAt=0, blinkStart=0; uint16_t blinkDur=BLINK_DUR_MS; bool blinking=false;
float gazeTX=0, gazeTY=0, gazeX=0, gazeY=0, gazeVX=0, gazeVY=0;
unsigned long fixationEndAt=0;
unsigned long nextScanAt=0, scanStart=0; bool scanning=false; int8_t scanDir=1;
EID curEmo = Neutral;
unsigned long emoChangeAt=0;
void scheduleEmo(){ emoChangeAt = millis() + urand(EMO_MIN_MS, EMO_MAX_MS); }
void scheduleBlink(){
if (curEmo == Angry) {
nextBlinkAt = millis() + urand(BLINK_MIN_MS_ANGRY, BLINK_MAX_MS_ANGRY);
blinkDur = BLINK_DUR_MS_ANGRY + ((urand(1, LONG_BLINK_CHANCE)==1)? LONG_BLINK_EXTRA/2 : 0);
} else {
nextBlinkAt = millis() + urand(BLINK_MIN_MS, BLINK_MAX_MS);
blinkDur = BLINK_DUR_MS + ((urand(1, LONG_BLINK_CHANCE)==1)? LONG_BLINK_EXTRA : 0);
}
}
float blink01(){
unsigned long now = millis();
if (!blinking && now >= nextBlinkAt) { blinking = true; blinkStart = now; }
if (blinking){
float p = min(1.0f, (now - blinkStart)/(float)blinkDur);
if (p >= 1.0f){ blinking=false; scheduleBlink(); }
return (p < 0.5f) ? p*2 : (1.0f - p)*2;
}
return 0.0f;
}
void scheduleFixation(){ fixationEndAt = millis() + urand(FIXATION_MS_MIN, FIXATION_MS_MAX); }
void scheduleScan(){ nextScanAt = millis() + urand(SCAN_INTERVAL_MIN, SCAN_INTERVAL_MAX); }
void newGazeNear(){
gazeTX = constrain((int)urand(-SACCADE_JUMP_X, SACCADE_JUMP_X), -GAZE_LIMIT_X, GAZE_LIMIT_X);
gazeTY = constrain((int)urand(-SACCADE_JUMP_Y, SACCADE_JUMP_Y), -GAZE_LIMIT_Y, GAZE_LIMIT_Y);
}
EID pickEmotion(){
float sum=0.0f;
for(int i=0;i<EMO_COUNT;i++) if (EMO_ENABLE[i]) sum += EMO_WEIGHT[i];
if (sum <= 1e-6f) return Neutral;
float r = (esp_random()/(float)UINT32_MAX) * sum;
for(int i=0;i<EMO_COUNT;i++){
if (!EMO_ENABLE[i]) continue;
if (r < EMO_WEIGHT[i]) return (EID)i;
r -= EMO_WEIGHT[i];
}
return Neutral;
}
void drawEyeFlat(const Eye& e, const Expr& ex, bool isLeft, float blinkF, float offX, float offY){
const int baseW = G.eyeW;
const int baseH = (int)round(G.eyeH * ex.hScale);
float align = ex.align + (isLeft ? ex.alignDeltaL : ex.alignDeltaR);
int yOff = ex.yOff + (isLeft ? ex.yOffDeltaL : ex.yOffDeltaR);
const bool forceFlatDuringBlink = (blinkF > 0.02f);
const int cx = e.cx + (int)round(offX);
const int cy = e.cy + yOff + (int)round(offY);
const int xL = cx - baseW/2, xR = cx + baseW/2;
const int yTop = cy - baseH/2, yBot = cy + baseH/2;
if (ex.useCut) {
u8g2.setDrawColor(1);
u8g2.drawRBox(xL, yTop, baseW, baseH, G.eyeR);
u8g2.setDrawColor(0);
if (isLeft) {
if (ex.cutInner) u8g2.drawTriangle(xL, yTop, xR, yTop, xR, yTop + ex.cutInner);
if (ex.cutOuter) u8g2.drawTriangle(xL, yTop, xR, yTop, xL, yTop + ex.cutOuter);
} else {
if (ex.cutInner) u8g2.drawTriangle(xL, yTop, xR, yTop, xL, yTop + ex.cutInner);
if (ex.cutOuter) u8g2.drawTriangle(xL, yTop, xR, yTop, xR, yTop + ex.cutOuter);
}
if (forceFlatDuringBlink) {
int h = max(1, (int)round(baseH * (1.0f - 0.90f*blinkF)));
int cover = baseH - h;
int upper = (cover * 3) / 4;
int lower = cover - upper;
if (upper > 0) u8g2.drawBox(xL, yTop, baseW, upper);
if (lower > 0) u8g2.drawBox(xL, yBot - lower, baseW, lower);
}
u8g2.setDrawColor(1);
return;
}
float openEff = (forceFlatDuringBlink)
? max(0.02f, 1.0f - 0.90f*blinkF)
: constrain(ex.open * (1.0f - 0.90f*blinkF), 0.02f, 1.0f);
int h = max(1, (int)round(baseH * openEff));
int drawTop = yTop + (int)round((baseH - h) * constrain(align, 0.0f, 1.0f));
if (!forceFlatDuringBlink && ex.rounded && openEff >= 0.9f) {
u8g2.drawRBox(xL, yTop, baseW, baseH, G.eyeR);
} else if (h >= 2) {
u8g2.drawBox(xL, drawTop, baseW, h);
} else {
u8g2.drawBox(xL, cy - 1, baseW, 1);
}
}
bool gRandom = true;
void setEmotion(EID x){ curEmo = x; scheduleBlink(); }
void setRandom(bool on){ gRandom = on; if(on) scheduleEmo(); }
void setStartHold(uint32_t ms){ emoChangeAt = millis() + ms; }
void setupEyes(){
G = Geom();
L.cx = OLED_WIDTH/2 - G.eyeGap/2; L.cy = G.eyeCy;
R.cx = OLED_WIDTH/2 + G.eyeGap/2; R.cy = G.eyeCy;
curEmo = Neutral;
emoChangeAt = millis() + START_HOLD_MS;
scheduleBlink(); scheduleFixation(); scheduleScan();
}
void drawEyesFrame(){
unsigned long now = millis();
if (now - lastFrame < (1000/FPS)) return;
lastFrame = now;
float scanOff = 0.0f;
if (!scanning && now >= nextScanAt){ scanning=true; scanStart=now; scanDir*=-1; }
if (scanning){
float t = (now - scanStart)/(float)SCAN_SWEEP_MS;
if (t >= 1.0f){ scanning=false; scheduleScan(); }
float tri = (t<0.5f)? t*2 : (1.0f - t)*2;
scanOff = scanDir * (tri * SCAN_SWEEP_PIX);
}
if (now >= fixationEndAt){ newGazeNear(); scheduleFixation(); }
float tx = gazeTX + scanOff, ty = gazeTY;
gazeVX = (gazeVX + SPRING_K * (tx - gazeX)) * SPRING_DAMP;
gazeVY = (gazeVY + SPRING_K * (ty - gazeY)) * SPRING_DAMP;
gazeX += gazeVX; gazeY += gazeVY;
if (gRandom && !blinking && now >= emoChangeAt) {
curEmo = pickEmotion(); scheduleEmo(); scheduleBlink();
}
float b = blink01();
float verg = (gazeX/GAZE_LIMIT_X) * 1.2f;
float offLX = gazeX - verg, offRX = gazeX + verg, offY = gazeY;
const Expr& ex = PRESET[curEmo];
u8g2.clearBuffer();
drawEyeFlat(L, ex, true , b, offLX, offY);
drawEyeFlat(R, ex, false, b, offRX, offY);
u8g2.sendBuffer();
}
/* ======================= 主流程狀態機(含最後一張新轉場) ======================= */
enum Stage {
ST_BOOT,
ST_SEQ_SHOW,
ST_SEQ_TRANS,
ST_IDLE,
ST_TO_EYES,
ST_EYES,
ST_FROM_EYES,
ST_LAST_TO_IDLE // ⭐ 新增
};
Stage stage = ST_BOOT;
uint8_t curIdx = 0;
uint8_t nextIdx = 1;
unsigned long stageEnd = 0;
unsigned long fullCycleEnd = 0;
unsigned long nextEyesAt = 0;
void startFullCycle(){
curIdx = 0;
nextIdx = 1;
stage = ST_SEQ_SHOW;
stageEnd= millis() + IMG_DUR_MS[curIdx];
fullCycleEnd = millis() + FULL_CYCLE_MS;
}
void startIdle(){
stage = ST_IDLE;
stageEnd= 0;
nextEyesAt = millis() + urand(EYES_INTERVAL_MIN_MS, EYES_INTERVAL_MAX_MS);
}
void setup(){
Wire.begin(I2C_SDA, I2C_SCL);
u8g2.begin();
u8g2.setContrast(255);
esp_random(); // 啟動 RNG
setupEyes();
startFullCycle();
}
void loop(){
unsigned long now = millis();
if (now >= fullCycleEnd && stage != ST_SEQ_SHOW && stage != ST_SEQ_TRANS){
startFullCycle();
}
switch(stage){
case ST_SEQ_SHOW: {
u8g2.clearBuffer(); drawImageCentered(curIdx); u8g2.sendBuffer();
if (now >= stageEnd){
if (curIdx == 5){
// ⭐ 第六張顯示完 → 進新轉場
stage = ST_LAST_TO_IDLE;
stageEnd = now + LAST_TO_IDLE_DUR_MS;
}else{
stage = ST_SEQ_TRANS;
stageEnd = now + TRANS_DUR_MS;
}
}
} break;
case ST_SEQ_TRANS: {
float p = 1.0f - (stageEnd - now)/(float)TRANS_DUR_MS; if (p<0) p=0; if (p>1) p=1;
drawCyberpunkTransition(curIdx, nextIdx, p);
if (now >= stageEnd){
curIdx = nextIdx;
if (curIdx == 5){
// ⭐ 抵達第六張 → 先顯示一段時間(LAST_IMG_HOLD_MS)
stage = ST_SEQ_SHOW;
stageEnd = now + LAST_IMG_HOLD_MS;
nextIdx = 0;
}else{
nextIdx = curIdx + 1;
stage = ST_SEQ_SHOW;
stageEnd = now + IMG_DUR_MS[curIdx];
}
}
} break;
case ST_LAST_TO_IDLE: {
float p = 1.0f - (stageEnd - now)/(float)LAST_TO_IDLE_DUR_MS; if (p<0) p=0; if (p>1) p=1;
drawLastToIdleTransition(/*last*/5, /*idle*/IDLE_IMG_INDEX, p);
if (now >= stageEnd){
startIdle();
}
} break;
case ST_IDLE: {
u8g2.clearBuffer(); drawImageCentered(IDLE_IMG_INDEX); u8g2.sendBuffer();
if (now >= nextEyesAt){
stage = ST_TO_EYES;
stageEnd = now + TRANS_DUR_MS;
}
} break;
case ST_TO_EYES: {
float p = 1.0f - (stageEnd - now)/(float)TRANS_DUR_MS; if (p<0) p=0; if (p>1) p=1;
drawCyberpunkTransition(IDLE_IMG_INDEX, IDLE_IMG_INDEX, p);
if (now >= stageEnd){
stage = ST_EYES;
stageEnd = now + 5000; // 眼睛顯示 5 秒(可調)
curEmo = pickEmotion(); scheduleBlink(); scheduleEmo();
}
} break;
case ST_EYES: {
drawEyesFrame();
if (now >= stageEnd){
stage = ST_FROM_EYES;
stageEnd = now + TRANS_DUR_MS;
}
} break;
case ST_FROM_EYES: {
float p = 1.0f - (stageEnd - now)/(float)TRANS_DUR_MS; if (p<0) p=0; if (p>1) p=1;
drawCyberpunkTransition(IDLE_IMG_INDEX, IDLE_IMG_INDEX, p);
if (now >= stageEnd){
nextEyesAt = now + urand(EYES_INTERVAL_MIN_MS, EYES_INTERVAL_MAX_MS);
stage = ST_IDLE;
}
} break;
default: break;
}
yield();
}