Explorar o código

🎨 优化乡源树动画

快乐的梦鱼 hai 2 días
pai
achega
1cfa727f1a

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 6 - 6
src/api/system/DefaultConfiguration.json


+ 56 - 16
src/components/canvas/MiniRender.ts

@@ -44,7 +44,7 @@ export namespace MiniRender {
 
   export type RenderObjectEventName = "added" | "removed" | "click" | 
     "touchstart" | "touchmove" | "touchend" | "touchcancel" |
-    "animationEnd";
+    "animationEnd" | "fadeInEnd" | "fadeOutEnd";
   export type RenderObjectEventHandler = (obj: RenderObject, data?: any) => void;
 
   export class RenderObject implements TransformLike, IRenderable, IUpdatable {
@@ -70,6 +70,8 @@ export namespace MiniRender {
 
     public data: any = null;
 
+    private _fade: { type: 'in' | 'out'; duration: number; elapsed: number; from: number; to: number } | null = null;
+
     private listeners = new Map<RenderObjectEventName, Set<RenderObjectEventHandler>>();
 
     constructor(id?: RenderObjectId) {
@@ -128,7 +130,28 @@ export namespace MiniRender {
       this.withTransform(rc, () => this.draw(rc));
     }
 
-    public update(_dtMs: number): void {}
+    public update(_dtMs: number): void {
+      if (this._fade) {
+        this._fade.elapsed += _dtMs;
+        const t = Math.min(1, this._fade.elapsed / this._fade.duration);
+        this.alpha = this._fade.from + (this._fade.to - this._fade.from) * t;
+        if (t >= 1) {
+          const type = this._fade.type;
+          this._fade = null;
+          this.emit(type === 'in' ? 'fadeInEnd' : 'fadeOutEnd');
+        }
+      }
+    }
+
+    public fadeIn(durationMs = 300): void {
+      this.alpha = 0;
+      this.visible = true;
+      this._fade = { type: 'in', duration: durationMs, elapsed: 0, from: 0, to: 1 };
+    }
+
+    public fadeOut(durationMs = 300): void {
+      this._fade = { type: 'out', duration: durationMs, elapsed: 0, from: this.alpha, to: 0 };
+    }
 
     public parentToLocal(px: number, py: number): { x: number; y: number } | null {
       const ax = this.anchorX * this.width;
@@ -551,7 +574,10 @@ export namespace MiniRender {
     }
 
     public override update(dtMs: number): void {
-      if (!this.playing || this._waitingForImage) return;
+      super.update(dtMs);
+
+      if (!this.playing || this._waitingForImage) 
+        return;
 
       const seq = this.getAnimationSequence();
       if (seq.length <= 1) return;
@@ -580,22 +606,26 @@ export namespace MiniRender {
 
     protected override draw(rc: RenderContext): void {
       const { ctx, scene } = rc;
-      if (!this.frames.length) return;
+      if (!this.frames.length) 
+        return;
 
       const fIdx = this.currentFrameIndex;
       const frame = this.frames[fIdx];
-      if (!frame) return;
+      if (!frame) 
+        return;
 
       const [sx, sy, sw, sh, originX = 0, originY = 0, imageIndex = 0] = frame;
-      if (sw <= 0 || sh <= 0) return;
-
-      const src = this.images[imageIndex] ?? this.images[0];
-      if (!src) return;
-
+      if (sw <= 0 || sh <= 0)
+        return;
+      
       // 默认使用帧尺寸作为显示尺寸
       if (this.width <= 0) this.width = sw;
       if (this.height <= 0) this.height = sh;
 
+      const src = this.images[imageIndex] ?? this.images[0];
+      if (!src) 
+        return;
+
       let img = this._resolvedImages[imageIndex] ?? null;
       img = img ?? scene.assets.getImageSync(src);
       if (!img) {
@@ -624,13 +654,23 @@ export namespace MiniRender {
     constructor(private canvas: RendeCanvasInterface) {}
 
     public getImage(src: string): Promise<any> {
-      if (this.imageResolved.has(src)) return Promise.resolve(this.imageResolved.get(src));
+      if (this.imageResolved.has(src)) 
+        return Promise.resolve(this.imageResolved.get(src));
       const inflight = this.imageCache.get(src);
-      if (inflight) return inflight;
-
-      const p = this.canvas.createImage(src).then((img) => {
-        this.imageResolved.set(src, img);
-        return img;
+      if (inflight) 
+        return inflight;
+
+      console.log('[AssetManager] Load image', src);
+
+      const p = new Promise<any>((resolve, reject) => {
+        this.canvas.createImage(src).then((img) => {
+          this.imageResolved.set(src, img);
+          resolve(img);
+          return img;
+        }).catch((e) => {
+          console.error('[AssetManager] Failed to load image', src, e);
+          reject(e);
+        })
       });
       this.imageCache.set(src, p);
       return p;

+ 11 - 1
src/pages/home/village/components/VillageTree.vue

@@ -80,7 +80,7 @@ const render = new MiniRender.Scene(
     if (!props.treeAnimProps)
       return;
     treeMain = new MiniRender.AnimateSprite({
-      framerate: props.treeAnimProps.framerate || 7,
+      framerate: props.treeAnimProps.framerate || 14,
       images: [ props.treeImage ],
       frames: props.treeAnimProps.frames || [],
       animations: props.treeAnimProps.animations,
@@ -92,6 +92,8 @@ const render = new MiniRender.Scene(
     treeMain.x = (this.width - treeMain.width) / 2;
     treeMain.y = (this.height - treeMain.height);
     treeMain.interactive = true;
+    treeMain.name = 'treeMain';
+    treeMain.play();
 
     animWater = new MiniRender.AnimateSprite({
       images: props.animWater,
@@ -105,6 +107,9 @@ const render = new MiniRender.Scene(
     animWater.x = (this.width - animWater.width) / 2 + this.precentXToPixel(props.animWaterAnimProps.offsetX || 0);
     animWater.y = (this.height - animWater.height) + this.precentYToPixel(props.animWaterAnimProps.offsetY || 0);
     animWater.on('animationEnd', () => {
+      animWater!.fadeOut();
+    });
+    animWater.on('fadeOutEnd', () => {
       animWater!.visible = false;
       emit('waterEnd');
     });
@@ -120,6 +125,9 @@ const render = new MiniRender.Scene(
     animFertilize.x = (this.width - animFertilize.width) / 2 + this.precentXToPixel(props.animFertilizeAnimProps.offsetX || 0);
     animFertilize.y = (this.height - animFertilize.height) + this.precentYToPixel(props.animFertilizeAnimProps.offsetY || 0);
     animFertilize.on('animationEnd', () => {
+      animFertilize!.fadeOut();
+    });
+    animFertilize.on('fadeOutEnd', () => {
       animFertilize!.visible = false;
       emit('fertilizeEnd');
     });
@@ -206,12 +214,14 @@ function playStateAnimation(state: 'collect' | 'water' | 'fertilize' | 'task' |
     case 'water':
       if (animWater) {
         animWater.visible = true;
+        animWater.fadeIn();
         animWater.play('default', { reset: true, once: true });
       }
       break;
     case 'fertilize':
       if (animFertilize) {
         animFertilize.visible = true;
+        animFertilize.fadeIn();
         animFertilize.play('default', { reset: true, once: true });
       }
       break;

+ 10 - 14
src/pages/home/village/introd/tree.vue

@@ -42,15 +42,10 @@
         <Height height="space.lg" />
         <FlexRow position="relative" center gap="gap.md" overflow="visible">
           <PopTextAnim :show="Boolean(popAnimText)" @end="popAnimText = ''">
-            <FlexRow 
-              center 
-              borderColor="border.secondary" borderWidth="1px" borderStyle="solid"
-              gap="gap.md" padding="padding.md" radius="radius.md" 
-              backgroundColor="background.tertiary"
-            >
+            <BoxMid direction="column" center>
               <Icon icon="https://xy.wenlvti.net/app_static/images/village/IconLight.png" :size="50" />
               <Text :text="popAnimText" fontConfig="contentText" fontSize="30rpx" color="#E79412" />
-            </FlexRow>
+            </BoxMid>
           </PopTextAnim>
           <Icon icon="https://xy.wenlvti.net/app_static/images/village/IconLight.png" :size="50" />
           <Text :text="`乡源光 ${currentVillage?.lightTotal || 0} ${currentVillage.treeName}`" fontConfig="contentText" fontSize="30rpx" color="#E79412" />
@@ -170,7 +165,7 @@ import { useVillageStore } from '@/store/village';
 import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
 import { useRequireLogin } from '@/common/composeabe/RequireLogin';
 import { showError } from '@/common/composeabe/ErrorDisplay';
-import { toast } from '@/components/utils/DialogAction';
+import { hideLoading, loading, toast } from '@/components/utils/DialogAction';
 import { navTo } from '@/components/utils/PageAction';
 import { RequestApiError, requireNotNull, SimpleTimer, waitTimeOut } from '@imengyu/imengyu-utils';
 import { useAuthStore } from '@/store/auth';
@@ -193,6 +188,7 @@ import BlessBuyDialog from '../dialogs/BlessBuyDialog.vue';
 import BlessSuccessDialog from '../dialogs/BlessSuccessDialog.vue';
 import TreeLevelUpDialog from '../dialogs/TreeLevelUpDialog.vue';
 import PopTextAnim from './components/PopTextAnim.vue';
+import BoxMid from '@/common/components/box/BoxMid.vue';
 
 const GROWTH_FEED_COUNT = 6;
 const DEFAULT_AVATAR = 'https://xy.wenlvti.net/app_static/images/village/PlaceholderVolunteerNew.png';
@@ -346,32 +342,32 @@ async function handlePickOrWaterOrFertilize(action: 'pick' | 'water' | 'fertiliz
       break;
   }
   try {
-    uni.showLoading({
-      title: '请稍后...',
-    });
+    loading('请稍后...');
     switch (action) {
       case 'pick':
         const res = await TreeApi.pick(villageStore.currentVillage.id);
+        hideLoading();
         villageTreeRef.value?.playStateAnimation('collect');
         toast(res, 4000);
         break;
       case 'water': {
-        await TreeApi.water(villageStore.currentVillage.id);
+        //await TreeApi.water(villageStore.currentVillage.id);
+        hideLoading();
         villageTreeRef.value?.playStateAnimation('water');
         lastRefreshAction.value = 'water';
         break;
       }
       case 'fertilize': {
         await TreeApi.fertilize(villageStore.currentVillage.id);
+        hideLoading();
         villageTreeRef.value?.playStateAnimation('fertilize');
         lastRefreshAction.value = 'fertilize';
         break;
       }
     }
     refreshVillageTreeInfo();
-    uni.hideLoading();
   } catch (e) {
-    uni.hideLoading();
+    hideLoading();
     if (e instanceof RequestApiError && typeof e.data === 'string') {
       toast(e.data, 4000);
       return;

+ 8 - 1
tools/gif_to_spritesheet.py

@@ -2,11 +2,12 @@
 将 GIF 转换为精灵图(Spritesheet),并输出 AnimateSprite 的 frames 定义。
 
 用法:
-  python gif_to_spritesheet.py input.gif [--cols 8] [--scale 50] [--output spritesheet.png]
+  python gif_to_spritesheet.py input.gif [--cols 8] [--scale 50] [--range 0-10] [--output spritesheet.png]
 
 参数:
   --scale   缩放百分比,如 50 表示缩小到 50%,200 表示放大到 200%(默认 100)
   --cols    每行帧数(默认 8)
+  --range   截取帧范围,格式为 start-end(包含两端),如 0-10 表示第0到第10帧(默认全部)
   --output  输出文件路径
 
 输出:
@@ -69,6 +70,7 @@ def main():
     parser.add_argument('input', help='输入 GIF 文件路径')
     parser.add_argument('--cols', type=int, default=8, help='每行帧数 (默认 8)')
     parser.add_argument('--scale', type=float, default=100, help='缩放百分比,如 50=缩小一半,200=放大两倍 (默认 100)')
+    parser.add_argument('--range', '-r', default=None, help='截取帧范围,格式: start-end (包含两端),如 0-10')
     parser.add_argument('--output', '-o', default=None, help='输出精灵图路径 (默认 <input>_spritesheet.png)')
     args = parser.parse_args()
 
@@ -78,6 +80,11 @@ def main():
     orig_w, orig_h = frames[0].size
     print(f'提取到 {len(frames)} 帧, 原始单帧尺寸: {orig_w}x{orig_h}')
 
+    if args.range:
+        start, end = map(int, args.range.split('-'))
+        frames = frames[start:end + 1]
+        print(f'截取帧范围: {start}-{end}, 共 {len(frames)} 帧')
+
     if args.scale != 100:
         frames = scale_frames(frames, args.scale)
         sw, sh = frames[0].size

BIN=BIN
tools/施肥GIF.gif


BIN=BIN
tools/施肥GIF_spritesheet.png


BIN=BIN
tools/浇水GIF.gif


BIN=BIN
tools/浇水GIF_spritesheet.png