Hardware components | ||||||
![]() |
| × | 1 | |||
| × | 1 | ||||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
![]() |
|
Story (English Version)
Introduction
FlowTIME is an interactive device that combines the M5Atom S3, a WS2812B 16×16 full-color LED matrix, and an accelerometer.A gentle tap turns the clock display into flowing liquid light, and when placed still, it transforms back into a clock.You can switch between various modes such as fluid animation, a 90-second hourglass, and a maze game.FlowTIME offers an experience that lets you not only see the time but also feel it.
Inspiration
Ever since I was a child, I’ve been fascinated by light and water.I wanted to create a device that captures both elements—a toy for my younger self.When you tilt it in your hand, light flows like liquid. Lift it up, and sand falls in the hourglass. You can even play a game.It’s a fusion of art, technology, and play—all in one device.
How It Works
- M5Atom S3 – Compact and powerful microcontroller module
- WS2812B 16×16 LED Matrix – Vivid full-color display
- Accelerometer – Detects tilt and vibration to change animations
Video
Modes
- Clock Mode – Digital Display
- Standard or mirrored display for use with reflective surfaces
- Tap the device to switch to Fluid Mode
- Leave it still for a while to return to Clock Mode
- Double-click the back of the AtomS3 to enable Mirror Mode
- Fluid Mode – Flowing Light
- LEDs flow like liquid according to gravity
- Watch pixels roll inside the frame
- Double-click the back of the AtomS3 to reverse gravity
- 90-Second Hourglass Mode
- Perfectly symmetrical LED sand animation
- Tilt sideways during the countdown to make the LED sand roll
- Maze Mode
- Reach the goal to automatically generate a new maze
- Rotate the device to guide the ball to the purple goal
Special Features
- Minimal build—anyone can make it
- No complex assembly required
- All exterior parts are fully 3D-printable (STL files provided)
- Fully tested code—just copy & paste to replicate the project
- Clock function uses Wi-Fi to get time from a Web RTC server
- This avoids the complexity and extra cost of a hardware RTC module
- SSID and password are hardcoded in the code, so be mindful of security
- Expandable with new features for motivated creators
- Great for kids who love LEDs
- Stylish enough for adults as a desk clock
- Screen cover is printed in glow-in-the-dark PLA for visibility even in complete darkness
Simple, intuitive operation—anyone around the world can enjoy it regardless of language---------------------------------------------------------------------------------------------------------------
ストーリー(日本語版)はじめにFlowTIME(フロータイム)は、M5Atom S3の加速度センサー と WS2812B 16×16 フルカラーLEDマトリクスを組み合わせた、触って楽しむ新感覚のデバイスです。少し叩くと時計の光が液体のように流れ、静かに置くと時計に変身。流体アニメーション・90秒砂時計・迷路ゲームなど、多彩なモードを切り替えて楽しめます。ただ時間を「見る」だけでなく、時間を「感じる」体験を提供します。
制作のきっかけ小さな頃から光や水が凄く好きで今回はそれを使ったデバイスを作りたいと思いました。子供の頃の自分に向けたおもちゃです。手の中で揺らすと光が流れ、持ち上げると砂が落ち、ゲームで遊ぶこともできる。そんな、アートとテクノロジーと遊びが融合した1台です。
ビデオ
- M5Atom S3 … 小型で高性能なマイコンモジュール
- WS2812B 16×16 LEDマトリクス … 鮮やかなフルカラー表現
- 加速度センサー … 傾きや振動を検出して動きを変化
搭載モード
時計モード – デジタル表示
- 時計モード – デジタル表示(鏡映え用の反転表示も可能)
- 時計を叩くと流体モードに切り替わります
- そっと置いてしばらくすると時計モードに戻ります
- 背面のAtomS3をダブルクリックするとミラーモードになり時計が反転表示します
流体モード – 重力に合わせて光が液体のように流れる
- 流体モード – 重力に合わせて光が液体のように流れる
- 枠内をLEDが転がるのが楽しいモード
- 背面のAtomS3をダブルクリックすると重力が反転します
90秒砂時計モード – LED粒子を使った完全対称デザインの砂時計
- 90秒砂時計モード – LED粒子を使った完全対称デザインの砂時計
- 砂時計が落ちている間も左右に傾けるとLEDの砂が転がります
迷路モード – ゴールに着くと自動で新しい迷路を生成
- 迷路モード – ゴールに着くと自動で新しい迷路を生成
- 回転させながらボールをパープルのゴールに誘導するシンプルゲームここ
このデバイスの特徴
誰でも作れるように最低限の構成にしています。複雑な工作は不要!簡単で楽しい!外装は全て3DPrinterで作製可能(STL配布しています)コードも全てチェック済み、コピペで同じものが作れます。時計機能はWiFi接続からWeb RTCで取得しています。( RTCモジュールだと複雑になるのとコストが増えるので今回は見送りました)コード内にSSIDとパスワードをハードコードする仕様です。セキュリティには注意が必要してください。やる気があれば、新たに機能を追加する事だって出来る!
LEDが好きな子供向けのデバイス!大人も楽しめる、置き時計としてもおしゃれ!
スクリーンには蓄光素材のPLAで印刷したカバーを取り付けています。真っ暗な場所でも、どこにあるか見つけることが可能!
シンプルな操作性で言語を超えて世界中の誰でも楽しく遊べます!
atomled_kansei
ArduinoModes include:
1.Clock (mirrored for reflection)
2.Flow (liquid-like motion when tilted)
3.Hourglass (90 seconds, fully packed sand)
4.Maze (goal changes color upon completion)
Button and motion control allow switching between modes.
// -----------------------------------------------------------------------------
// M5Atom S3 + WS2812 1616 v12.8-hourglass90 + shake-gate
// Modes: CLOCK / FLOW / HOURGLASS(90s, fully packed & symmetric) / MAZE
// Btn: Single=next / Double=IMU invert save / Triple=Clock mirror save / Long(2s)=OFF
// Wi-Fi + NTP(JST) SSID: khome / PASS: Kiwamot0
// -----------------------------------------------------------------------------
#include <M5Unified.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <WiFi.h>
#include <esp_random.h>
#include <time.h>
#include <cmath>
#include <cstring>
// ===== Board / LED ===========================================================
constexpr uint8_t DIN_PIN = 2; // AtomS3: G2 WS2812 DIN
constexpr uint8_t PWR_PIN = 16; // FET
constexpr int W = 16, H = 16;
constexpr int NLED = W * H;
Adafruit_NeoPixel strip(NLED, DIN_PIN, NEO_GRB + NEO_KHZ800);
//
inline uint16_t idx(int x,int y){ return (y & 1) ? (y*W + (W-1-x)) : (y*W + x); }
// ===== Wi-Fi / NTP ===========================================================
const char* WIFI_SSID = "khome";
const char* WIFI_PASS = "Kiwamot0";
constexpr uint32_t WIFI_TIMEOUT_MS = 10000;
// ===== Modes / Buttons =======================================================
enum Mode : uint8_t { MODE_CLOCK, MODE_FLOW, MODE_HOURGLASS, MODE_MAZE };
Mode mode = MODE_CLOCK;
bool powerOn = true;
constexpr uint16_t LONG_PRESS_MS = 2000;
uint32_t btnHoldStart = 0;
uint32_t lastActive_ms = 0; // FLOW
constexpr uint32_t FLOW_IDLE_TO_CLOCK_MS = 30000; //30
static uint32_t lastClick=0;
static uint8_t clickCnt=0;
constexpr uint16_t MULTICLICK_WINDOW_MS = 350;
// ===== Preferences ===========================================================
Preferences prefs;
bool invertX=false, invertY=false; // IMU
bool clockMirrorX = true; //
constexpr const char* PREF_NS = "lab_v128";
constexpr const char* KEY_INV = "invMask";
constexpr const char* KEY_CMIR = "clockMX";
void saveIMU(){ prefs.putUChar(KEY_INV, (invertX?1:0)|(invertY?2:0)); }
void saveClockMirror(){ prefs.putBool(KEY_CMIR, clockMirrorX); }
void loadPrefs(){
prefs.begin(PREF_NS, false);
uint8_t m = prefs.getUChar(KEY_INV, 0);
invertX = (m & 1);
invertY = (m & 2);
clockMirrorX = prefs.getBool(KEY_CMIR, true);
}
// ===== Common Tuning =========================================================
constexpr uint8_t MAIN_BRIGHT = 48;
// ===== FLOWCLOCK ========================
//
constexpr float LP_ALPHA = 0.12f; // 0..1
constexpr float SHAKE_JERK_THR = 0.35f; //
constexpr uint16_t SHAKE_DEBOUNCE_MS= 250; //
constexpr float STILL_JERK_THR = 0.05f; //
constexpr uint32_t STILL_HOLD_MS = 10000; // CLOCK10s
static float axLP=0, ayLP=0, azLP=0; // LP
static uint32_t lastShakeMs = 0;
static uint32_t stillSinceMs = 0;
// ===== FLOW ====================================================
constexpr float G_GAIN = 36000.0f;
constexpr float DEADZONE_G = 0.03f;
constexpr float CURVE_GAMMA = 1.4f;
constexpr float FRICTION = 0.995f;
constexpr float BOUNCE = 0.75f;
constexpr int32_t VEL_CLAMP = 16*256*6;
constexpr bool GRAVITY_INVERT_Y = true;
constexpr uint8_t TAIL_KEEP = 216;
constexpr float KERNEL_SHARP= 1.6f;
constexpr uint8_t SPARK_GAIN = 60;
constexpr int SEP_DIST = 180;
constexpr float SEP_PUSH = 0.5f;
constexpr int BALLS = 35;
struct Ball{ int32_t px,py,vx,vy; uint32_t col; };
Ball balls[BALLS];
static uint32_t accR[NLED], accG[NLED], accB[NLED];
inline uint8_t fade8(uint8_t v, uint8_t keep){ return (uint16_t(v)*keep)>>8; }
uint32_t hsv(uint8_t h, uint8_t s, uint8_t v){
uint8_t r,g,b; uint8_t region=h/43, rem=(h-region*43)*6;
uint8_t p=(v*(255-s))>>8, q=(v*(255-((s*rem)>>8)))>>8, t=(v*(255-((s*(255-rem))>>8)))>>8;
switch(region){
case 0: r=v; g=t; b=p; break; case 1: r=q; g=v; b=p; break;
case 2: r=p; g=v; b=t; break; case 3: r=p; g=q; b=v; break;
case 4: r=t; g=p; b=v; break; default:r=v; g=p; b=q; break;
}
return (uint32_t)r<<16 | (uint32_t)g<<8 | b;
}
void initBalls(){
for(int i=0;i<BALLS;++i){
balls[i].px = (W/2)*256; balls[i].py = (H/2)*256;
balls[i].vx = (int32_t)((int32_t)(esp_random()%401) - 200);
balls[i].vy = (int32_t)((int32_t)(esp_random()%401) - 200);
uint8_t hue = (uint8_t)((i * 255) / BALLS);
balls[i].col = hsv(hue, 255, 255);
}
}
static uint8_t lutPow[256], lutPowInv[256];
void buildLUT(){
for(int i=0;i<256;++i){
float x = i/255.0f;
float a = powf(x, KERNEL_SHARP);
float b = powf(1.0f - x, KERNEL_SHARP);
lutPow[i] = (uint8_t)(a*255.0f + 0.5f);
lutPowInv[i] = (uint8_t)(b*255.0f + 0.5f);
}
}
inline void accAdd(uint16_t p, uint32_t col, uint16_t w){
uint8_t cr = col >> 16, cg = (col >> 8) & 0xFF, cb = col & 0xFF;
accR[p] += (uint32_t)cr * w;
accG[p] += (uint32_t)cg * w;
accB[p] += (uint32_t)cb * w;
}
inline void drawSubpixelSharpLUT(int32_t px, int32_t py, uint32_t col){
int x0 = px >> 8, y0 = py >> 8;
uint8_t fx = px & 0xFF, fy = py & 0xFF;
uint16_t u0 = lutPowInv[fx], u1 = lutPow[fx];
uint16_t v0 = lutPowInv[fy], v1 = lutPow[fy];
uint16_t w00 = (u0*v0)>>8, w10 = (u1*v0)>>8, w01 = (u0*v1)>>8, w11 = (u1*v1)>>8;
if((unsigned)x0 < W && (unsigned)y0 < H) accAdd(idx(x0, y0 ), col, w00);
if((unsigned)(x0+1) < W && (unsigned)y0 < H) accAdd(idx(x0+1, y0 ), col, w10);
if((unsigned)x0 < W && (unsigned)(y0+1) < H) accAdd(idx(x0, y0+1), col, w01);
if((unsigned)(x0+1) < W && (unsigned)(y0+1) < H) accAdd(idx(x0+1, y0+1), col, w11);
if((unsigned)x0 < W && (unsigned)y0 < H) accAdd(idx(x0, y0 ), col, SPARK_GAIN);
}
uint32_t tPrev_us = 0;
// ===== Clock (3x5) ===========================================================
const uint8_t FONT[10][5] = {
{0b111,0b101,0b101,0b101,0b111}, {0b010,0b110,0b010,0b010,0b111},
{0b111,0b001,0b111,0b100,0b111}, {0b111,0b001,0b111,0b001,0b111},
{0b101,0b101,0b111,0b001,0b001}, {0b111,0b100,0b111,0b001,0b111},
{0b111,0b100,0b111,0b101,0b111}, {0b111,0b001,0b001,0b001,0b001},
{0b111,0b101,0b111,0b101,0b111}, {0b111,0b101,0b111,0b001,0b111}
};
inline uint16_t clockIndex(int x, int y){
int xx = clockMirrorX ? (W-1-x) : x; //
return idx(xx, y);
}
inline void clockPix(int x, int y, uint32_t c){
if ((unsigned)x < W && (unsigned)y < H) strip.setPixelColor(clockIndex(x,y), c);
}
void drawDigit3x5_clock(int d,int ox,int oy,uint32_t col){
for(int y=0;y<5;++y) for(int x=0;x<3;++x)
if(FONT[d][y] & (1<<(2-x))) clockPix(ox+x, oy+y, col);
}
void renderClock(bool colonOn){
strip.clear();
strip.setBrightness(MAIN_BRIGHT);
time_t now=time(nullptr); struct tm lt; localtime_r(&now,<);
int hh=lt.tm_hour, mm=lt.tm_min;
int x=0, y=5; // 354 + 1 = 15
uint32_t cH = strip.Color(0,128,255);
uint32_t cM = strip.Color(0,255,96);
drawDigit3x5_clock(hh/10, x, y, cH); x+=4;
drawDigit3x5_clock(hh%10, x, y, cH); x+=4;
if(colonOn){ clockPix(x, y+1, cH); clockPix(x, y+3, cH); }
x+=1;
drawDigit3x5_clock(mm/10, x, y, cM); x+=4;
drawDigit3x5_clock(mm%10, x, y, cM);
strip.show();
}
// ===== Gravity helper ========================================================
void xyGravityCurve(float ax, float ay, float &gx, float &gy){
if(invertX) ax = -ax;
if(invertY) ay = -ay;
float mag = sqrtf(ax*ax + ay*ay);
if(mag < 1e-6f){ gx=gy=0; return; }
float norm = (mag - DEADZONE_G)/(1.0f - DEADZONE_G);
if(norm < 0){ gx=gy=0; return; }
norm = powf(norm, CURVE_GAMMA);
gx = (ax/mag) * norm;
gy = (ay/mag) * norm;
if(GRAVITY_INVERT_Y) gy = -gy; //
}
// ===== FLOW step =============================================================
void physicsStepFLOW(float gx, float gy, float dt){
memset(accR, 0, sizeof(accR));
memset(accG, 0, sizeof(accG));
memset(accB, 0, sizeof(accB));
int32_t minU=0, maxU=(W-1)*256;
for(int i=0;i<BALLS;++i){
balls[i].vx += (int32_t)(gx*G_GAIN*dt);
balls[i].vy += (int32_t)(gy*G_GAIN*dt);
balls[i].vx = (int32_t)(balls[i].vx * FRICTION);
balls[i].vy = (int32_t)(balls[i].vy * FRICTION);
if(balls[i].vx > VEL_CLAMP) balls[i].vx = VEL_CLAMP;
if(balls[i].vx < -VEL_CLAMP) balls[i].vx = -VEL_CLAMP;
if(balls[i].vy > VEL_CLAMP) balls[i].vy = VEL_CLAMP;
if(balls[i].vy < -VEL_CLAMP) balls[i].vy = -VEL_CLAMP;
balls[i].px += (int32_t)(balls[i].vx * dt);
balls[i].py += (int32_t)(balls[i].vy * dt);
bool hit=false;
if(balls[i].px < minU){ balls[i].px=minU; balls[i].vx = -(int32_t)(balls[i].vx*BOUNCE); hit=true; }
if(balls[i].px > maxU){ balls[i].px=maxU; balls[i].vx = -(int32_t)(balls[i].vx*BOUNCE); hit=true; }
if(balls[i].py < minU){ balls[i].py=minU; balls[i].vy = -(int32_t)(balls[i].vy*BOUNCE); hit=true; }
if(balls[i].py > maxU){ balls[i].py=maxU; balls[i].vy = -(int32_t)(balls[i].vy*BOUNCE); hit=true; }
if(hit){ balls[i].vx = (int32_t)(balls[i].vx * 0.90f); balls[i].vy = (int32_t)(balls[i].vy * 0.90f); }
drawSubpixelSharpLUT(balls[i].px, balls[i].py, balls[i].col);
}
//
for(int i=0;i<BALLS;++i){
for(int j=i+1;j<BALLS;++j){
int32_t dx = balls[j].px - balls[i].px;
int32_t dy = balls[j].py - balls[i].py;
if(abs(dx) < SEP_DIST && abs(dy) < SEP_DIST){
int64_t d2 = (int64_t)dx*dx + (int64_t)dy*dy;
int32_t thresh2 = (int32_t)SEP_DIST * (int32_t)SEP_DIST;
if(d2 == 0){
int32_t jx = (int32_t)((int32_t)(esp_random()%101) - 50);
int32_t jy = (int32_t)((int32_t)(esp_random()%101) - 50);
balls[i].px -= jx; balls[i].py -= jy;
balls[j].px += jx; balls[j].py += jy;
} else if(d2 < thresh2){
float d = sqrtf((float)d2);
float nx = dx / d, ny = dy / d;
float corr = (SEP_DIST - d) * SEP_PUSH;
int32_t cx = (int32_t)(nx * corr);
int32_t cy = (int32_t)(ny * corr);
balls[i].px -= cx; balls[i].py -= cy;
balls[j].px += cx; balls[j].py += cy;
}
}
}
}
// HDR max
for(int p=0; p<NLED; ++p){
uint32_t prev = strip.getPixelColor(p);
uint8_t rTail = fade8((prev>>16)&0xFF, TAIL_KEEP);
uint8_t gTail = fade8((prev>> 8)&0xFF, TAIL_KEEP);
uint8_t bTail = fade8((prev )&0xFF, TAIL_KEEP);
uint16_t rAcc = accR[p] >> 8; if(rAcc>255) rAcc=255;
uint16_t gAcc = accG[p] >> 8; if(gAcc>255) gAcc=255;
uint16_t bAcc = accB[p] >> 8; if(bAcc>255) bAcc=255;
uint8_t r = (rTail > (uint8_t)rAcc) ? rTail : (uint8_t)rAcc;
uint8_t g = (gTail > (uint8_t)gAcc) ? gTail : (uint8_t)gAcc;
uint8_t b = (bTail > (uint8_t)bAcc) ? bTail : (uint8_t)bAcc;
strip.setPixelColor(p, ((uint32_t)r<<16)|((uint32_t)g<<8)|b);
}
strip.show();
}
// ===== HOURGLASS (90s, fully packed & symmetric) ============================
static bool hgInside[H][W]; //
static bool hgSand[H][W]; //
constexpr float HG_DURATION_SEC = 90.0f; // 90
constexpr float HG_MIN_TILT = 0.30f; // |gy|
static float hgRateTPS = 0.0f; // /
static float hgTokens = 0.0f; //
static uint32_t hgLastMs = 0;
uint32_t hgSandColor = 0xFFD070;
uint32_t hgWallColor = 0x203050;
uint8_t hgBGFade = 190;
// (0..7)(15..8)
void buildHourglassMask(){
memset(hgInside, 0, sizeof(hgInside));
const float midX = (W-1)*0.5f; // 7.5
const float neckHalf = 1.0f; // =1 x=7,8
const float maxHalf = 7.0f; //
const float curve = 1.35f; //
auto buildRow=[&](int y)->void{
float t = (7 - y) / 7.0f; // y=0t=1, y=7t=0
float half = neckHalf + (maxHalf - neckHalf) * powf(t, curve);
// +0.5
int xL = (int)ceilf (midX - half);
int xR = (int)floorf(midX + half);
if(xL<0) xL=0; if(xR>=W) xR=W-1;
for(int x=xL;x<=xR;++x) hgInside[y][x]=true;
};
for(int y=0;y<=7;++y) buildRow(y);
for(int y=0;y<=7;++y){
int ym = H-1-y; // 15..8
for(int x=0;x<W;++x) hgInside[ym][x] = hgInside[y][x];
}
// 22y=7/8, x=7/8
for(int y=0;y<H;y++){
if(y==7 || y==8){
for(int x=0;x<W;x++) hgInside[y][x]=false;
hgInside[y][7]=hgInside[y][8]=true;
}
}
}
inline bool isUpperRow(int y, int gySign){ return gySign>=0 ? (y<=7) : (y>=8); }
void fillUpperBulb(int gySign){
memset(hgSand, 0, sizeof(hgSand));
for(int y=0;y<H;y++){
if(!isUpperRow(y,gySign)) continue;
for(int x=0;x<W;x++){
if(hgInside[y][x]) hgSand[y][x]=true;
}
}
}
void hgRecalcRate(int gySign){
int upper=0;
for(int y=0;y<H;y++){
if(!isUpperRow(y,gySign)) continue;
for(int x=0;x<W;x++) if(hgInside[y][x] && hgSand[y][x]) upper++;
}
hgRateTPS = (upper>0) ? ((float)upper / HG_DURATION_SEC) : 0.0f;
hgTokens = 0.0f;
hgLastMs = millis();
}
inline bool isThroatDest(int x,int y){ return ( (x==7||x==8) && (y==7||y==8) ); }
inline bool isCrossingThroat(int sy,int sx,int ny,int nx){
if(!isThroatDest(nx,ny)) return false;
return ( (sy==7 && ny==8) || (sy==8 && ny==7) );
}
inline bool insideHG(int x,int y){ return (unsigned)x<W && (unsigned)y<H && hgInside[y][x]; }
static inline void hgDir(float gx, float gy,
int &dx0,int &dy0,int &dxa,int &dya,int &dxb,int &dyb,
int &sx,int &sy,int &ex,int &ey){
if(fabsf(gy) >= fabsf(gx)){
dy0 = (gy>=0)? 1:-1; dx0 = 0;
dxa = (gx>=0)? +1:-1; dya = dy0;
dxb = -dxa; dyb = dy0;
sy = (dy0>0)? H-1:0; ey = (dy0>0)? -1:H; sx=0; ex=W;
}else{
dx0 = (gx>=0)? 1:-1; dy0 = 0;
dya = (gy>=0)? +1:-1; dxa = dx0;
dyb = -dya; dxb = dx0;
sx = (dx0>0)? W-1:0; ex = (dx0>0)? -1:W; sy=0; ey=H;
}
}
void stepHourglass(float gx, float gy){
//
uint32_t now = millis();
if(hgLastMs==0) hgLastMs = now;
float dt = (now - hgLastMs)*0.001f;
hgLastMs = now;
if(fabsf(gy) > HG_MIN_TILT) hgTokens += hgRateTPS * dt;
//
static int lastSign = +1;
int gySign = (gy>=0)? +1 : -1;
if(gySign != lastSign && fabsf(gy) > 0.6f){
fillUpperBulb(gySign);
hgRecalcRate(gySign);
lastSign = gySign;
}
int dx0,dy0,dxa,dya,dxb,dyb,sx,sy,ex,ey;
hgDir(gx,gy,dx0,dy0,dxa,dya,dxb,dyb,sx,sy,ex,ey);
auto tryMove = [&](int y,int x,int ny,int nx)->bool{
if(!insideHG(nx,ny) || hgSand[ny][nx]) return false;
if(isCrossingThroat(y,x,ny,nx)){
if(hgTokens >= 1.0f){
hgSand[y][x]=false; hgSand[ny][nx]=true; hgTokens -= 1.0f;
return true;
} else return false;
}else{
hgSand[y][x]=false; hgSand[ny][nx]=true; return true;
}
};
if(dy0!=0){ //
for(int y=(dy0>0? H-1:0); (dy0>0? y>=0:y<H); y+=(dy0>0? -1:+1)){
for(int x=0;x<W;++x){
if(!hgSand[y][x]) continue;
int ny=y+dy0, nx=x+dx0;
if(tryMove(y,x,ny,nx)) continue;
int ay=y+dya, ax=x+dxa;
int by=y+dyb, bx=x+dxb;
if(tryMove(y,x,ay,ax)) continue;
(void)tryMove(y,x,by,bx);
}
}
}else{ //
for(int x=(dx0>0? W-1:0); (dx0>0? x>=0:x<W); x+=(dx0>0? -1:+1)){
for(int y=0;y<H;++y){
if(!hgSand[y][x]) continue;
int ny=y+dy0, nx=x+dx0;
if(tryMove(y,x,ny,nx)) continue;
int ay=y+dya, ax=x+dxa;
int by=y+dyb, bx=x+dxb;
if(tryMove(y,x,ay,ax)) continue;
(void)tryMove(y,x,by,bx);
}
}
}
}
void renderHourglass(){
//
for(int p=0;p<NLED;++p){
uint32_t c = strip.getPixelColor(p);
uint8_t r = fade8((c>>16)&0xFF, hgBGFade);
uint8_t g = fade8((c>> 8)&0xFF, hgBGFade);
uint8_t b = fade8((c )&0xFF, hgBGFade);
strip.setPixelColor(p, ((uint32_t)r<<16)|((uint32_t)g<<8)|b);
}
//
for(int y=0;y<H;++y){
for(int x=0;x<W;++x){
if(!hgInside[y][x]){
strip.setPixelColor(idx(x,y), hgWallColor);
}
}
}
//
for(int y=0;y<H;++y){
for(int x=0;x<W;++x){
if(hgSand[y][x]){
strip.setPixelColor(idx(x,y), hgSandColor);
}
}
}
strip.show();
}
// /90s
bool hgFirstEnter = true;
void enterHourglass(float gy){
int gySign = (gy>=0)? +1:-1;
fillUpperBulb(gySign);
hgRecalcRate(gySign);
hgFirstEnter = false;
}
// ===== MAZE =========================================
constexpr int MZ_CW=7, MZ_CH=7;
static bool mzVisited[MZ_CH][MZ_CW];
static uint8_t mzHWall[MZ_CH+1][MZ_CW];
static uint8_t mzVWall[MZ_CH][MZ_CW+1];
int mzPx=1, mzPy=1;
int mzGx=13, mzGy=13;
uint32_t mzWallColor = 0x203030;
uint32_t mzPathColor = 0x001010;
uint32_t mzBallColor = 0x80FF40;
uint32_t mzGoalBase = 0xFF20FF; //
void mzClear(){
memset(mzVisited,0,sizeof(mzVisited));
for(int y=0;y<=MZ_CH;y++) for(int x=0;x<MZ_CW;x++) mzHWall[y][x]=1;
for(int y=0;y<MZ_CH;y++) for(int x=0;x<=MZ_CW;x++) mzVWall[y][x]=1;
}
void mzDFS(int cx,int cy){
mzVisited[cy][cx]=true;
int dirs[4]={0,1,2,3};
for(int i=0;i<4;i++){ int j=esp_random()%4; int t=dirs[i]; dirs[i]=dirs[j]; dirs[j]=t; }
for(int k=0;k<4;k++){
int d=dirs[k]; int nx=cx, ny=cy;
if(d==0) ny=cy-1; else if(d==1) nx=cx+1; else if(d==2) ny=cy+1; else nx=cx-1;
if(nx<0||ny<0||nx>=MZ_CW||ny>=MZ_CH) continue;
if(!mzVisited[ny][nx]){
if(d==0) mzHWall[cy][cx]=0;
if(d==2) mzHWall[cy+1][cx]=0;
if(d==1) mzVWall[cy][cx+1]=0;
if(d==3) mzVWall[cy][cx]=0;
mzDFS(nx,ny);
}
}
}
inline bool mzIsWallPixel(int X,int Y){
if(X==0||Y==0||X==14||Y==14) return true;
if((X%2==1)&&(Y%2==1)) return false;
if(Y%2==0 && X%2==1){ int cx=(X-1)/2, ry=Y/2; return mzHWall[ry][cx]; }
if(X%2==0 && Y%2==1){ int cy=(Y-1)/2, cx=X/2; return mzVWall[cy][cx]; }
return true;
}
void initMaze(){
mzClear(); mzDFS(0,0);
mzPx=1; mzPy=1; mzGx=13; mzGy=13;
}
static inline uint32_t scaleColor(uint32_t c, float s){
uint8_t r=(c>>16)&0xFF, g=(c>>8)&0xFF, b=c&0xFF;
int R=(int)(r*s); if(R>255)R=255;
int G=(int)(g*s); if(G>255)G=255;
int B=(int)(b*s); if(B>255)B=255;
return ((uint32_t)R<<16)|((uint32_t)G<<8)|B;
}
void stepMaze(float gx, float gy){
static uint32_t lastStep = 0;
if (millis() - lastStep < 60) return;
lastStep = millis();
int dx = (fabsf(gx) >= fabsf(gy)) ? ((gx > 0) ? +2 : (gx < 0 ? -2 : 0)) : 0;
int dy = (fabsf(gy) > fabsf(gx)) ? ((gy > 0) ? +2 : (gy < 0 ? -2 : 0)) : 0;
auto canMove = [&](int x,int y,int dx,int dy)->bool{
int mx = x + dx/2, my = y + dy/2;
int tx = x + dx, ty = y + dy;
if (mx<0||my<0||mx>14||my>14||tx<0||ty<0||tx>14||ty>14) return false;
if (mzIsWallPixel(mx,my)) return false;
if (mzIsWallPixel(tx,ty)) return false;
return true;
};
int nx=mzPx, ny=mzPy;
if (dx!=0 && canMove(nx,ny,dx,0)) nx += dx;
if (dy!=0 && canMove(nx,ny,0,dy)) ny += dy;
mzPx = nx; mzPy = ny;
if (mzPx==mzGx && mzPy==mzGy){
for (int r=0; r<7; r++){
for (int y=0; y<15; y++) for (int x=0; x<15; x++){
uint32_t c = 0;
if (!mzIsWallPixel(x,y))
c = ((abs(x-mzGx)+abs(y-mzGy))<=r) ? 0xFFFFFF : 0x000000;
strip.setPixelColor(idx(x,y), c);
}
strip.show(); delay(25);
}
initMaze();
}
}
void renderMaze(){
for(int y=0;y<15;y++){
for(int x=0;x<15;x++){
uint32_t c = mzIsWallPixel(x,y) ? mzWallColor : mzPathColor;
strip.setPixelColor(idx(x,y), c);
}
}
//
float pulse = 0.5f + 0.5f * sinf(millis()*0.010f);
strip.setPixelColor(idx(mzGx,mzGy), scaleColor(mzGoalBase, 0.4f + 0.6f*pulse));
//
strip.setPixelColor(idx(mzPx,mzPy), 0xFFFFFF);
strip.show();
}
// ===== Power / Wi-Fi / Setup / Loop =========================================
void enterOff(){ strip.clear(); strip.show(); digitalWrite(PWR_PIN, LOW); powerOn=false; }
void leaveOff(){
digitalWrite(PWR_PIN, HIGH);
strip.setBrightness(MAIN_BRIGHT);
initBalls();
tPrev_us = micros();
lastActive_ms = millis();
mode = MODE_CLOCK;
powerOn = true;
}
void setupWiFiAndTime(){
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && (millis() - start) < WIFI_TIMEOUT_MS){ delay(200); }
configTime(9*3600, 0, "ntp.jst.mfeed.ad.jp", "ntp.nict.jp", "pool.ntp.org");
struct tm tinfo; getLocalTime(&tinfo, 5000);
}
void setup(){
pinMode(PWR_PIN, OUTPUT); digitalWrite(PWR_PIN, HIGH);
M5.begin();
strip.begin(); strip.setBrightness(MAIN_BRIGHT); strip.show();
loadPrefs();
setupWiFiAndTime();
buildLUT();
initBalls();
buildHourglassMask();
initMaze();
tPrev_us = micros();
lastActive_ms = millis();
renderClock(true); //
}
void loop(){
M5.update();
if(!powerOn){
if(M5.BtnA.wasPressed()){ leaveOff(); }
delay(50);
return;
}
//
if(M5.BtnA.wasPressed()){
uint32_t now = millis();
if(now - lastClick <= MULTICLICK_WINDOW_MS){ clickCnt++; }
else { clickCnt=1; }
lastClick = now;
}
//
if(clickCnt && (millis()-lastClick) > MULTICLICK_WINDOW_MS){
if(clickCnt>=3){ clockMirrorX = !clockMirrorX; saveClockMirror(); if(mode==MODE_CLOCK) renderClock(true); }
else if(clickCnt==2){ invertX=!invertX; invertY=!invertY; saveIMU(); }
else {
mode = (Mode)((mode + 1) % 4);
if(mode==MODE_CLOCK) renderClock(true);
if(mode==MODE_FLOW){ initBalls(); lastActive_ms=millis(); stillSinceMs=0; }
if(mode==MODE_HOURGLASS){ hgFirstEnter=true; } //
}
clickCnt=0;
}
// 2s OFF
if(M5.BtnA.isPressed()){
if(btnHoldStart==0) btnHoldStart = millis();
else if(millis()-btnHoldStart >= LONG_PRESS_MS){ enterOff(); btnHoldStart=0; return; }
} else { btnHoldStart = 0; }
// ==== IMU & ==========================================
float ax,ay,az; M5.Imu.getAccel(&ax,&ay,&az);
// LP
axLP = axLP + LP_ALPHA*(ax - axLP);
ayLP = ayLP + LP_ALPHA*(ay - ayLP);
azLP = azLP + LP_ALPHA*(az - azLP);
// LPXY
float jx = ax - axLP;
float jy = ay - ayLP;
float jerk = sqrtf(jx*jx + jy*jy);
//
uint32_t nowMs = millis();
if(jerk < STILL_JERK_THR){
if(stillSinceMs==0) stillSinceMs = nowMs;
}else{
stillSinceMs = 0;
}
// FLOW
float gx, gy; xyGravityCurve(ax, ay, gx, gy);
// ==== CLOCK ================================================================
if(mode==MODE_CLOCK){
static uint32_t prevHalf = 0xFFFFFFFF;
uint32_t half = millis()/500;
if(half != prevHalf){ renderClock((half % 2)==0); prevHalf = half; }
// FLOW
if(jerk > SHAKE_JERK_THR && (nowMs - lastShakeMs) > SHAKE_DEBOUNCE_MS){
lastShakeMs = nowMs;
mode=MODE_FLOW;
initBalls();
lastActive_ms=nowMs;
stillSinceMs=0;
}
delay(5);
return;
}
// ==== FLOW ================================================================
if(mode==MODE_FLOW){
//
if(jerk > SHAKE_JERK_THR){ lastActive_ms = nowMs; lastShakeMs = nowMs; }
//
if(stillSinceMs && (nowMs - stillSinceMs) > STILL_HOLD_MS){
mode = MODE_CLOCK; renderClock(true); return;
}
//
if(nowMs - lastActive_ms > FLOW_IDLE_TO_CLOCK_MS){
mode = MODE_CLOCK; renderClock(true); return;
}
uint32_t tNow = micros();
float dt = (tNow - tPrev_us) * 1e-6f; tPrev_us = tNow;
physicsStepFLOW(gx, gy, dt);
delayMicroseconds(200);
return;
}
// ==== HOURGLASS ===========================================================
if(mode==MODE_HOURGLASS){
if(hgFirstEnter){ enterHourglass(gy); }
stepHourglass(gx, gy);
renderHourglass();
delay(18);
return;
}
// ==== MAZE ================================================================
if(mode==MODE_MAZE){
stepMaze(gx, gy);
renderMaze();
delay(10);
return;
}
}
Comments