feat(lcd): animate fortune results with shared GFX layers

This commit is contained in:
chenjintao
2026-10-10 11:36:54 +08:00
parent bf06618848
commit ee32b4d63f
26 changed files with 3063 additions and 11 deletions
@@ -659,6 +659,20 @@ static void handle_command_line(char *line)
handle_prepare_rotate(key_version);
} else if (strcmp(cmd, "lcd_test") == 0) {
handle_lcd_test();
} else if (strcmp(cmd, "lcd_fortune_test") == 0) {
/* 有界、仅显示测试;不调用云抽签、不写 NVS、不改变语义选择映射。 */
if (argc != 2 || argv[1][0] < '0' || argv[1][0] > '4' || argv[1][1] != '\0') {
cli_write_error("invalid_fortune_index");
return;
}
const domain_expression_request_t request = {
.id = DOMAIN_EXPRESSION_CHOUQIAN,
.trigger = DOMAIN_EXPRESSION_TRIGGER_FACTORY_LCD_TEST,
.chouqian_result_index_valid = true,
.chouqian_result_index = (size_t)(argv[1][0] - '0'),
};
if (domain_runtime_request_expression(&request) == ESP_OK) cli_write_ok();
else cli_write_error("lcd_fortune_test_failed");
} else {
cli_write_error("unknown_command");
}
+2
View File
@@ -31,6 +31,8 @@ if(CONFIG_TQ_LCD_EMOTE_GFX_TEST)
list(APPEND platform_srcs
"src/lcd_emote_assets.c"
"src/lcd_emote_listening.c"
"src/lcd_emote_fortune.c"
"src/generated/emote_gfx/fortune_assets.c"
"src/generated/emote_gfx/idle_base.c"
"src/generated/emote_gfx/idle_eyes_open.c"
"src/generated/emote_gfx/listening_base.c"
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,114 @@
{
"canvas": [
320,
240
],
"window": [
56,
20,
208,
220
],
"layers": [
{
"name": "pile",
"width": 208,
"height": 76,
"x": 56,
"y": 164,
"raw_bytes": 47424,
"lz4_bytes": 9417
},
{
"name": "back",
"width": 63,
"height": 113,
"x": 0,
"y": 0,
"raw_bytes": 21357,
"lz4_bytes": 4179
},
{
"name": "edge",
"width": 41,
"height": 110,
"x": 0,
"y": 0,
"raw_bytes": 13530,
"lz4_bytes": 4389
},
{
"name": "front",
"width": 100,
"height": 190,
"x": 0,
"y": 0,
"raw_bytes": 57000,
"lz4_bytes": 10774
},
{
"name": "question",
"width": 27,
"height": 50,
"x": 0,
"y": 0,
"raw_bytes": 4050,
"lz4_bytes": 817
},
{
"name": "rod",
"width": 169,
"height": 56,
"x": 0,
"y": 40,
"raw_bytes": 28392,
"lz4_bytes": 2094
},
{
"name": "text_buyun",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2638
},
{
"name": "text_heping",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2851
},
{
"name": "text_shangshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2069
},
{
"name": "text_xiaoji",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2210
},
{
"name": "text_zhongshang",
"width": 48,
"height": 104,
"x": 0,
"y": 0,
"raw_bytes": 14976,
"lz4_bytes": 2109
}
],
"note": "黑背景归零;共享爪子,签文字形来自原图;旧 PNG 保留"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,32 @@
#pragma once
#include "lcd_emote_assets.h"
/* 平台私有图层与几何;领域仍选择资产序列,不引入业务状态机。 */
enum { LCD_FORTUNE_PILE, LCD_FORTUNE_BACK, LCD_FORTUNE_EDGE,
LCD_FORTUNE_FRONT, LCD_FORTUNE_QUESTION, LCD_FORTUNE_ROD,
LCD_FORTUNE_SHARED_COUNT, LCD_FORTUNE_RESULT_COUNT = 5 };
#define LCD_FORTUNE_X 56
#define LCD_FORTUNE_Y 20
#define LCD_FORTUNE_WIDTH 208U
#define LCD_FORTUNE_HEIGHT 220U
#define LCD_FORTUNE_MOTION_MS 2500U
#define LCD_FORTUNE_TOTAL_MS 4000U
#define LCD_FORTUNE_HOLD_MS 1500U
#define LCD_FORTUNE_STEP_MS 34U
typedef struct {
uint16_t width, height, x, y;
size_t compressed_size;
const uint8_t *data;
} lcd_fortune_asset_t;
extern const lcd_fortune_asset_t lcd_fortune_shared[LCD_FORTUNE_SHARED_COUNT];
extern const lcd_fortune_asset_t lcd_fortune_text[LCD_FORTUNE_RESULT_COUNT];
/* 有界 LZ4 block 解码,无动态内存;拒绝截断、无效回指及长度不符。 */
bool lcd_emote_fortune_decode(const uint8_t *src, size_t size, uint8_t *dst, size_t capacity);
extern const char *const lcd_fortune_result_dirs[LCD_FORTUNE_RESULT_COUNT];
/* RGB565 高字节在前 + 独立 A8;所有像素由调用方拥有。 */
typedef struct { uint16_t width, height; const uint8_t *pixels; } lcd_fortune_texture_t;
/* 输出不透明 RGB565,完整重画固定局部窗口,确保旧轮廓恢复而不残影。 */
bool lcd_emote_fortune_build_frame(uint8_t *dst, size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text, uint32_t time_ms);
File diff suppressed because it is too large Load Diff
+207
View File
@@ -38,6 +38,7 @@
#include "gfx.h"
#include "lcd_emote_assets.h"
#include "lcd_emote_listening.h"
#include "lcd_emote_fortune.h"
#endif
#define LCD_DIRECT_SPI_HOST SPI2_HOST
@@ -970,6 +971,7 @@ static esp_err_t lcd_direct_listening_prepare(void)
static esp_err_t lcd_direct_emote_select_face(bool listening)
{
const lcd_emote_rle_asset_t *base = listening ? &listening_base_rle_asset : &idle_base_rle_asset;
ESP_RETURN_ON_ERROR(gfx_obj_set_visible(s_emote_background, true), TAG, "show face base failed");
ESP_RETURN_ON_ERROR(gfx_img_set_src(s_emote_background,
listening ? &s_listening_base_image : &s_emote_base_image), TAG, "switch face base failed");
ESP_RETURN_ON_ERROR(gfx_obj_set_pos(s_emote_background, base->x, base->y), TAG, "switch face position failed");
@@ -1166,6 +1168,203 @@ static esp_err_t lcd_direct_play_emote_face_until_pending(const lcd_direct_reque
}
return result != ESP_OK ? result : lock_err;
}
typedef struct {
lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT], text;
uint8_t *pixels[LCD_FORTUNE_SHARED_COUNT + 1], *frame;
gfx_image_dsc_t image, rod_image;
gfx_obj_t *object;
} lcd_direct_fortune_scene_t;
/* 极端锁/解绑故障保留已绑定资源到重启,不能释放给 GFX 的悬空指针。 */
static lcd_direct_fortune_scene_t *s_fortune_orphan;
static int lcd_direct_fortune_sequence_index(const lcd_direct_request_t *request)
{
if (request == NULL || request->step_count != 6) return -1;
for (size_t i = 0; i < 6; ++i) {
const platform_lcd_presentation_step_t *step = &request->steps[i];
if (step->asset_dir == NULL || step->frame_index != i % 3 ||
step->icon_id != PLATFORM_LCD_PRESENTATION_NO_ICON ||
step->delay_ms != (i == 5 ? 1500U : 500U)) return -1;
if (i < 3 && strcmp(step->asset_dir, "chouqian_qianzhi") != 0) return -1;
if (i >= 3 && strcmp(step->asset_dir, request->steps[3].asset_dir) != 0) return -1;
}
for (size_t i = 0; i < LCD_FORTUNE_RESULT_COUNT; ++i)
if (strcmp(request->steps[3].asset_dir, lcd_fortune_result_dirs[i]) == 0) return (int)i;
return -1; /* 不匹配的通用步骤仍使用原 PNG 播放,不偷换其他动作。 */
}
static void lcd_direct_fortune_free(lcd_direct_fortune_scene_t *scene)
{
if (scene == NULL) return;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) free(scene->pixels[i]);
free(scene->frame);
free(scene);
}
static lcd_direct_fortune_scene_t *lcd_direct_fortune_prepare(size_t index)
{
if (index >= LCD_FORTUNE_RESULT_COUNT) return NULL;
lcd_direct_fortune_scene_t *scene = heap_caps_calloc(1, sizeof(*scene), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene == NULL) return NULL;
for (size_t i = 0; i <= LCD_FORTUNE_SHARED_COUNT; ++i) {
const lcd_fortune_asset_t *asset = i < LCD_FORTUNE_SHARED_COUNT ?
&lcd_fortune_shared[i] : &lcd_fortune_text[index];
size_t bytes = (size_t)asset->width * asset->height * 3;
scene->pixels[i] = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->pixels[i] == NULL || !lcd_emote_fortune_decode(asset->data,
asset->compressed_size, scene->pixels[i], bytes)) {
lcd_direct_fortune_free(scene);
return NULL;
}
lcd_fortune_texture_t texture = {asset->width, asset->height, scene->pixels[i]};
if (i < LCD_FORTUNE_SHARED_COUNT) scene->shared[i] = texture;
else scene->text = texture;
}
const size_t bytes = LCD_FORTUNE_WIDTH * LCD_FORTUNE_HEIGHT * 2;
scene->frame = heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (scene->frame == NULL) {lcd_direct_fortune_free(scene); return NULL;}
scene->image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = LCD_FORTUNE_WIDTH, .header.h = LCD_FORTUNE_HEIGHT,
.data_size = bytes, .data = scene->frame,
};
const lcd_fortune_asset_t *rod = &lcd_fortune_shared[LCD_FORTUNE_ROD];
scene->rod_image = (gfx_image_dsc_t) {
.header.cf = GFX_COLOR_FORMAT_RGB565A8, .header.magic = C_ARRAY_HEADER_MAGIC,
.header.w = rod->width, .header.h = rod->height,
.data_size = (size_t)rod->width * rod->height * 3,
.data = scene->pixels[LCD_FORTUNE_ROD],
};
return scene;
}
/* 同一任务持锁退出并解绑对象,然后才能释放一次性 PSRAM 图层。 */
static esp_err_t lcd_direct_fortune_detach(lcd_direct_fortune_scene_t *scene)
{
esp_err_t err = gfx_emote_lock(s_emote_handle);
if (err != ESP_OK) return err;
portENTER_CRITICAL(&s_state_lock);
s_emote_active = false;
portEXIT_CRITICAL(&s_state_lock);
err = lcd_direct_emote_select_face(false);
if (scene->object != NULL) {
esp_err_t deleted = gfx_obj_delete(scene->object);
if (deleted == ESP_OK) scene->object = NULL;
if (err == ESP_OK) err = deleted;
}
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
return err != ESP_OK ? err : unlock_err;
}
static esp_err_t lcd_direct_play_fortune(size_t index)
{
if (s_fortune_orphan != NULL || s_emote_transfer_fault) return ESP_ERR_INVALID_STATE;
if (lcd_direct_should_stop_idle()) return ESP_ERR_INVALID_STATE;
const int64_t prepare_start_us = esp_timer_get_time();
lcd_direct_fortune_scene_t *scene = lcd_direct_fortune_prepare(index);
if (scene == NULL) return ESP_ERR_NOT_SUPPORTED;
esp_err_t result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) {lcd_direct_fortune_free(scene); return result;}
bool started = false;
bool rod_hidden = false;
if (lcd_direct_should_stop_idle()) result = ESP_ERR_INVALID_STATE;
if (result == ESP_OK) {
scene->object = gfx_img_create(s_emote_disp);
result = scene->object != NULL ? ESP_OK : ESP_ERR_NO_MEM;
}
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_eyes_open, false);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_closed_eyes, false);
if (result == ESP_OK && s_emote_cached_eyes != NULL) result = gfx_obj_set_visible(s_emote_cached_eyes, false);
if (result == ESP_OK && s_listening_eyes != NULL) result = gfx_obj_set_visible(s_listening_eyes, false);
if (result == ESP_OK) result = gfx_img_set_src(s_emote_background, &scene->rod_image);
if (result == ESP_OK) result = gfx_obj_set_pos(s_emote_background, 0, 40);
if (result == ESP_OK) result = gfx_obj_set_visible(s_emote_background, true);
if (result == ESP_OK) result = gfx_obj_set_pos(scene->object, LCD_FORTUNE_X, LCD_FORTUNE_Y);
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK) result = lcd_direct_emote_update_hud(true, NULL);
esp_err_t unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
size_t pixel_bytes = scene->image.data_size;
for (size_t i = 0; i < LCD_FORTUNE_SHARED_COUNT; ++i)
pixel_bytes += (size_t)scene->shared[i].width * scene->shared[i].height * 3;
pixel_bytes += (size_t)scene->text.width * scene->text.height * 3;
ESP_LOGI(TAG, "Fortune layers asset=%s pixels_psram=%u scene_bytes=%u prepare_us=%llu",
lcd_fortune_result_dirs[index], (unsigned)pixel_bytes, (unsigned)sizeof(*scene),
(unsigned long long)(esp_timer_get_time() - prepare_start_us));
const int64_t start_us = esp_timer_get_time();
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t frames = 0, draw_attempts = 0, late = 0, max_draw_us = 0;
uint64_t total_draw_us = 0;
#endif
for (uint32_t ms = 0; result == ESP_OK;) {
if (lcd_direct_should_stop_idle()) {result = ESP_ERR_INVALID_STATE; break;}
const int64_t draw_start_us = esp_timer_get_time();
result = gfx_emote_lock(s_emote_handle);
if (result != ESP_OK) break;
if (!lcd_emote_fortune_build_frame(scene->frame, scene->image.data_size,
scene->shared, &scene->text, ms)) result = ESP_ERR_INVALID_SIZE;
if (result == ESP_OK) result = gfx_img_set_src(scene->object, &scene->image);
if (result == ESP_OK && ms >= 600 && !rod_hidden) {
result = gfx_obj_set_visible(s_emote_background, false);
rod_hidden = result == ESP_OK;
}
if (result == ESP_OK) result = lcd_direct_emote_update_hud(false, NULL);
if (result == ESP_OK) {
portENTER_CRITICAL(&s_state_lock);
s_emote_active = true;
portEXIT_CRITICAL(&s_state_lock);
if (!started) gfx_disp_refresh_all(s_emote_disp);
started = true;
result = lcd_direct_emote_refresh();
}
unlock_err = gfx_emote_unlock(s_emote_handle);
if (result == ESP_OK) result = unlock_err;
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
uint32_t draw_us = (uint32_t)(esp_timer_get_time() - draw_start_us);
total_draw_us += draw_us;
++draw_attempts;
if (draw_us > max_draw_us) max_draw_us = draw_us;
if (draw_us >= LCD_FORTUNE_STEP_MS * 1000) ++late;
if (result == ESP_OK) ++frames;
#else
(void)draw_start_us;
#endif
if (result != ESP_OK) break;
if (ms == LCD_FORTUNE_MOTION_MS) break;
uint32_t next_ms = ms + LCD_FORTUNE_STEP_MS;
if (next_ms > LCD_FORTUNE_MOTION_MS) next_ms = LCD_FORTUNE_MOTION_MS;
result = lcd_direct_emote_wait_until(start_us + (int64_t)next_ms * 1000);
ms = next_ms;
}
#if CONFIG_TQ_LCD_EMOTE_FPS_LOG
if (started && frames > 0) {
uint64_t motion_us = esp_timer_get_time() - start_us;
uint64_t fps100 = lcd_direct_emote_fps_x100(frames, motion_us);
ESP_LOGI(TAG, "fortune_fps result=%s frames=%u motion_us=%llu update_fps=%llu.%02llu "
"draw_avg_us=%llu draw_max_us=%u late_frames=%u status=%s",
lcd_fortune_result_dirs[index], (unsigned)frames, (unsigned long long)motion_us,
(unsigned long long)(fps100 / 100), (unsigned long long)(fps100 % 100),
(unsigned long long)(total_draw_us / draw_attempts), (unsigned)max_draw_us, (unsigned)late,
esp_err_to_name(result));
}
#endif
/* 运动仍锚定同一起点;迟到不能吞掉用户阅读签文所需的末帧停留。 */
if (result == ESP_OK) result = lcd_direct_emote_wait_until(
esp_timer_get_time() + (int64_t)LCD_FORTUNE_HOLD_MS * 1000);
esp_err_t detach_err = lcd_direct_fortune_detach(scene);
if (detach_err == ESP_OK) lcd_direct_fortune_free(scene);
else {
s_fortune_orphan = scene;
s_emote_transfer_fault = true;
ESP_LOGE(TAG, "Fortune detach failed; bound pixels retained, restart required");
}
if (result == ESP_OK) result = detach_err;
if (result == ESP_OK && !lcd_direct_should_stop_idle()) lcd_direct_signal_completion();
if (!started && detach_err == ESP_OK && result != ESP_ERR_INVALID_STATE)
return ESP_ERR_NOT_SUPPORTED;
return result;
}
#endif
static uint16_t lcd_direct_swap_color(uint16_t color)
@@ -2457,6 +2656,14 @@ static esp_err_t lcd_direct_play_step_sequence(const lcd_direct_request_t *reque
ESP_ERR_INVALID_ARG,
TAG,
"invalid step sequence");
#if CONFIG_TQ_LCD_EMOTE_GFX_TEST
int fortune = lcd_direct_fortune_sequence_index(request);
if (fortune >= 0 && s_emote_ready && LCD_DIRECT_WIDTH == 320 && LCD_DIRECT_HEIGHT == 240) {
esp_err_t err = lcd_direct_play_fortune((size_t)fortune);
if (err != ESP_ERR_NOT_SUPPORTED) return err;
ESP_LOGW(TAG, "Fortune layers unavailable; using PNG fallback");
}
#endif
for (size_t i = 0; i < request->step_count; ++i) {
(void)lcd_direct_service_preheat();
+200
View File
@@ -0,0 +1,200 @@
#include "lcd_emote_fortune.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
typedef struct { float x, y, u, v; } vertex_t;
static bool read_length(const uint8_t *src,size_t size,size_t *pos,size_t *length)
{
if (*length!=15) return true;
unsigned value;
do {
if (*pos>=size || *length>SIZE_MAX-255) return false;
value=src[(*pos)++]; *length+=value;
} while (value==255);
return true;
}
bool lcd_emote_fortune_decode(const uint8_t *src,size_t size,uint8_t *dst,size_t capacity)
{
if (!src || !size || !dst || !capacity) return false;
size_t pos=0,written=0;
while (pos<size) {
unsigned token=src[pos++]; size_t literals=token>>4;
if (!read_length(src,size,&pos,&literals) || literals>size-pos || literals>capacity-written) return false;
memcpy(dst+written,src+pos,literals);written+=literals;pos+=literals;
if (pos==size) return written==capacity && (token&15)==0;
if (size-pos<2) return false;
size_t offset=src[pos]|((size_t)src[pos+1]<<8);pos+=2;
size_t match=token&15;
if (!offset || offset>written || !read_length(src,size,&pos,&match) ||
match>SIZE_MAX-4 || match+4>capacity-written) return false;
match+=4;
for (size_t i=0;i<match;++i) {dst[written]=dst[written-offset];++written;}
}
return false;
}
static float smooth(float v) { v = fmaxf(0, fminf(1, v)); return v*v*(3-2*v); }
static uint16_t color_at(const lcd_fortune_texture_t *t, size_t i)
{ return ((uint16_t)t->pixels[i*2]<<8) | t->pixels[i*2+1]; }
static void blend(uint8_t *dst, int x, int y, uint16_t color, unsigned alpha)
{
x -= LCD_FORTUNE_X; y -= LCD_FORTUNE_Y;
if (x < 0 || y < 0 || x >= LCD_FORTUNE_WIDTH || y >= LCD_FORTUNE_HEIGHT || !alpha) return;
size_t i = ((size_t)y*LCD_FORTUNE_WIDTH+x)*2;
uint16_t old = ((uint16_t)dst[i]<<8) | dst[i+1];
unsigned r = (((color>>11)&31)*alpha + ((old>>11)&31)*(255-alpha)+127)/255;
unsigned g = (((color>>5)&63)*alpha + ((old>>5)&63)*(255-alpha)+127)/255;
unsigned b = ((color&31)*alpha + (old&31)*(255-alpha)+127)/255;
uint16_t out = (r<<11)|(g<<5)|b;
dst[i] = out>>8; dst[i+1] = out;
}
/* 双线性采样在预乘 Alpha 空间,避免黑色透明边缘造成轮廓暗边。 */
static void sample(uint8_t *dst, int x, int y, const lcd_fortune_texture_t *t,
float u, float v, unsigned opacity)
{
if (u < 0 || v < 0 || u > 1 || v > 1 || !opacity) return;
float sx = u*(t->width-1), sy = v*(t->height-1);
int x0 = (int)sx, y0 = (int)sy;
int x1 = x0+1 < t->width ? x0+1 : x0, y1 = y0+1 < t->height ? y0+1 : y0;
float fx = sx-x0, fy = sy-y0;
const size_t indices[4] = {(size_t)y0*t->width+x0, (size_t)y0*t->width+x1,
(size_t)y1*t->width+x0, (size_t)y1*t->width+x1};
const float weights[4] = {(1-fx)*(1-fy), fx*(1-fy), (1-fx)*fy, fx*fy};
float a=0, r=0, g=0, b=0;
size_t count = (size_t)t->width*t->height;
for (int i=0; i<4; ++i) {
float aw = weights[i]*t->pixels[count*2+indices[i]];
uint16_t c = color_at(t, indices[i]);
a += aw; r += ((c>>11)&31)*aw; g += ((c>>5)&63)*aw; b += (c&31)*aw;
}
if (a < 0.5f) return;
float inv_a = 1/a;
uint16_t c = ((unsigned)(r*inv_a+0.5f)<<11)|((unsigned)(g*inv_a+0.5f)<<5)|(unsigned)(b*inv_a+0.5f);
blend(dst,x,y,c,(unsigned)(a*opacity/255+0.5f));
}
static void blit(uint8_t *dst, const lcd_fortune_texture_t *t, int x, int y, unsigned opacity)
{
size_t count=(size_t)t->width*t->height;
for (int sy=0; sy<t->height; ++sy) for (int sx=0; sx<t->width; ++sx) {
size_t i=(size_t)sy*t->width+sx;
unsigned a=t->pixels[count*2+i]*opacity/255;
if (a) blend(dst,x+sx,y+sy,color_at(t,i),a);
}
}
static void triangle(uint8_t *dst, const lcd_fortune_texture_t *t,
vertex_t a, vertex_t b, vertex_t c, bool include_shared)
{
float det=(b.x-a.x)*(c.y-a.y)-(c.x-a.x)*(b.y-a.y);
if (fabsf(det)<0.01f) return;
int left=(int)floorf(fminf(a.x,fminf(b.x,c.x))), right=(int)ceilf(fmaxf(a.x,fmaxf(b.x,c.x)));
int top=(int)floorf(fminf(a.y,fminf(b.y,c.y))), bottom=(int)ceilf(fmaxf(a.y,fmaxf(b.y,c.y)));
if (left<LCD_FORTUNE_X) left=LCD_FORTUNE_X;
if (top<LCD_FORTUNE_Y) top=LCD_FORTUNE_Y;
if (right>LCD_FORTUNE_X+LCD_FORTUNE_WIDTH) right=LCD_FORTUNE_X+LCD_FORTUNE_WIDTH;
if (bottom>LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT) bottom=LCD_FORTUNE_Y+LCD_FORTUNE_HEIGHT;
float inv=1/det;
for (int y=top; y<bottom; ++y) for (int x=left; x<right; ++x) {
float px=x+0.5f-a.x, py=y+0.5f-a.y;
float wb=(px*(c.y-a.y)-py*(c.x-a.x))*inv;
float wc=((b.x-a.x)*py-(b.y-a.y)*px)*inv;
/* 两个三角形共边只属于一方,避免 Alpha 轮廓重复混合。 */
if (wb>=0 && wc>=0 && (include_shared ? wb+wc<=1 : wb+wc<1)) sample(dst,x,y,t,
a.u+wb*(b.u-a.u)+wc*(c.u-a.u), a.v+wb*(b.v-a.v)+wc*(c.v-a.v),255);
}
}
static vertex_t project(float u, float v, float width, float height, float theta,
float cx, float cy, bool side, bool front)
{
float c=cosf(theta), s=sinf(theta), roll=0.025f*s;
float x=side?0:(front?0.5f-u:u-0.5f)*width;
float z=side?(u-0.5f)*10:0;
float X=x*c-z*s, Z=x*s+z*c, k=320/(320+Z);
float xx=X*k, yy=(v-0.5f)*height*k;
return (vertex_t){cx+xx*cosf(roll)-yy*sinf(roll),cy+xx*sinf(roll)+yy*cosf(roll),u,v};
}
static void paw(uint8_t *dst, const lcd_fortune_texture_t *t, float width, float height,
float theta, float cx, float cy, bool side, bool front)
{
/* 16 条纵向纹理带近似透视;每带仅两个三角形,非三维模型。 */
for (int i=0; i<16; ++i) {
float u0=i/16.0f,u1=(i+1)/16.0f;
vertex_t a=project(u0,0,width,height,theta,cx,cy,side,front);
vertex_t b=project(u1,0,width,height,theta,cx,cy,side,front);
vertex_t c=project(u0,1,width,height,theta,cx,cy,side,front);
vertex_t d=project(u1,1,width,height,theta,cx,cy,side,front);
triangle(dst,t,a,b,c,true); triangle(dst,t,d,c,b,false);
}
}
static void flip_question(uint8_t *dst,const lcd_fortune_texture_t *t,float theta,float cx,float cy)
{
/* 小问号与签的正面共用透视平面;正面展开时不突然贴出平面图案。 */
vertex_t a=project(0.30f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t b=project(0.70f,0.43f,60,114,theta,cx,cy,false,true);
vertex_t c=project(0.30f,0.816f,60,114,theta,cx,cy,false,true);
vertex_t d=project(0.70f,0.816f,60,114,theta,cx,cy,false,true);
/* 正面 local X 反向,因此按屏幕方向摆放 UV,签文不镜像。 */
a.u=c.u=0; b.u=d.u=1; a.v=b.v=0; c.v=d.v=1;
triangle(dst,t,a,b,c,true);triangle(dst,t,d,c,b,false);
}
static void overlay(uint8_t *dst,const lcd_fortune_texture_t *t,
float left,float top,float width,float height,float reveal)
{
for (int y=(int)floorf(top); y<(int)ceilf(top+height*reveal); ++y)
for (int x=(int)floorf(left); x<(int)ceilf(left+width); ++x)
sample(dst,x,y,t,(x+0.5f-left)/width,(y+0.5f-top)/height,255);
}
static void stars(uint8_t *dst,float progress)
{
static const uint8_t points[][3]={{89,62,4},{232,91,6},{75,132,5},{251,145,4},
{119,195,5},{229,211,6},{198,40,4},{79,191,4}};
unsigned alpha=(unsigned)(255*progress);
for (size_t i=0;i<sizeof(points)/sizeof(points[0]);++i) {
int radius=points[i][2];
for (int j=-radius;j<=radius;++j) {
unsigned a=alpha*(radius+1-abs(j))/(radius+1);
blend(dst,points[i][0]+j,points[i][1],0xffad,a);
if (j) blend(dst,points[i][0],points[i][1]+j,0xffad,a);
}
}
}
bool lcd_emote_fortune_build_frame(uint8_t *dst,size_t capacity,
const lcd_fortune_texture_t shared[LCD_FORTUNE_SHARED_COUNT],
const lcd_fortune_texture_t *text,uint32_t time_ms)
{
const size_t bytes=LCD_FORTUNE_WIDTH*LCD_FORTUNE_HEIGHT*2;
if (!dst || capacity<bytes || !shared || !text || !text->pixels ||
!text->width || !text->height || text->width>320 || text->height>240) return false;
for (int i=0;i<LCD_FORTUNE_SHARED_COUNT;++i)
if (!shared[i].pixels || !shared[i].width || !shared[i].height ||
shared[i].width>320 || shared[i].height>240) return false;
memset(dst,0,bytes);
if (time_ms>LCD_FORTUNE_MOTION_MS) time_ms=LCD_FORTUNE_MOTION_MS;
float zoom=smooth(((float)time_ms-1900)/600);
if (time_ms<600) blit(dst,&shared[LCD_FORTUNE_ROD],lcd_fortune_shared[LCD_FORTUNE_ROD].x,
lcd_fortune_shared[LCD_FORTUNE_ROD].y,255);
float width=60+40*zoom,height=114+76*zoom,cx=160,cy=94+25*zoom;
if (time_ms<600) {
cy=212-118*smooth(time_ms/600.0f);
paw(dst,&shared[LCD_FORTUNE_BACK],60,110,0,cx,cy,false,false);
} else if (time_ms<1200) {
float theta=3.14159265f*smooth((time_ms-600)/600.0f), c=cosf(theta), s=sinf(theta);
bool side=fabsf(c)<0.17f,front=c<0;
const lcd_fortune_texture_t *t=&shared[side?LCD_FORTUNE_EDGE:front?LCD_FORTUNE_FRONT:LCD_FORTUNE_BACK];
paw(dst,t,60,side?110:114,theta,cx+4*s,cy-3*s,side,front);
if (front && !side) flip_question(dst,&shared[LCD_FORTUNE_QUESTION],theta,cx+4*s,cy-3*s);
} else {
paw(dst,&shared[LCD_FORTUNE_FRONT],width,height,3.14159265f,cx,cy,false,true);
}
if (time_ms>=1200 && time_ms<1600)
overlay(dst,&shared[LCD_FORTUNE_QUESTION],cx-12,cy-8,24,44,1);
if (time_ms>=1600) {
float left=cx-width/2,top=cy-height/2;
overlay(dst,text,left+width*0.26f,top+height*70/190,width*0.48f,height*104/190,
smooth((time_ms-1600)/300.0f));
}
/* 下方爪子遮挡抽出时的签底;放大阶段淡出,最终背景纯黑。 */
blit(dst,&shared[LCD_FORTUNE_PILE],lcd_fortune_shared[LCD_FORTUNE_PILE].x,
lcd_fortune_shared[LCD_FORTUNE_PILE].y,(unsigned)(255*(1-zoom)));
if (zoom>0) stars(dst,zoom);
return true;
}