Forráskód Böngészése

📦 乡源树动画和参数对接接口

快乐的梦鱼 4 hete
szülő
commit
c67213a148

+ 54 - 41
src/api/light/LightVillageApi.ts

@@ -7,48 +7,49 @@ export class VillageListItem extends DataModel<VillageListItem> {
     super(VillageListItem, "活动列表");
     this.setNameMapperCase('Camel', 'Snake');
     this._convertTable = {
-      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      id: { clientSide: 'number', clientSideRequired: true },
       isLight: { clientSide: 'boolean' },
-      region: { clientSide: 'number', serverSide: 'number' },
-      rank: { clientSide: 'number', serverSide: 'number' },
-      volunteerCount: { clientSide: 'number', serverSide: 'number' },
-      followCount: { clientSide: 'number', serverSide: 'number' },
-      collectCount: { clientSide: 'number', serverSide: 'number' },
-      villageType: { clientSide: 'number', serverSide: 'number' },
-      altitude: { clientSide: 'number', serverSide: 'number' },
-      longitude: { clientSide: 'number', serverSide: 'number' },
-      latitude: { clientSide: 'number', serverSide: 'number' },
-      age: { clientSide: 'number', serverSide: 'number' },
-      area: { clientSide: 'number', serverSide: 'number' },
-      villageArea: { clientSide: 'number', serverSide: 'number' },
-      traditionalBuildings: { clientSide: 'number', serverSide: 'number' },
-      ichLevel: { clientSide: 'number', serverSide: 'number' },
-      historyLevel: { clientSide: 'number', serverSide: 'number' },
-      touristLevel: { clientSide: 'number', serverSide: 'number' },
-      isFeaturedVillage: { clientSide: 'number', serverSide: 'number' },
-      registeredPopulationYear: { clientSide: 'number', serverSide: 'number' },
-      registeredPopulation: { clientSide: 'number', serverSide: 'number' },
-      permanentPopulationYear: { clientSide: 'number', serverSide: 'number' },
-      permanentPopulation: { clientSide: 'number', serverSide: 'number' },
-      personalAnnualIncomeYear: { clientSide: 'number', serverSide: 'number' },
-      villageAnnualIncomeYear: { clientSide: 'number', serverSide: 'number' },
-      points: { clientSide: 'number', serverSide: 'number' },
-      light: { clientSide: 'number', serverSide: 'number' },
-      lightTotal: { clientSide: 'number', serverSide: 'number' },
-      fruitOutput: { clientSide: 'number', serverSide: 'number' },
-      fruitRemain: { clientSide: 'number', serverSide: 'number' },
-      fruitToday: { clientSide: 'number', serverSide: 'number' },
-      level: { clientSide: 'number', serverSide: 'number' },
-      weight: { clientSide: 'number', serverSide: 'number' },
-      vipLevel: { clientSide: 'number', serverSide: 'number' },
-      overviewId: { clientSide: 'number', serverSide: 'number' },
-      myOverviewId: { clientSide: 'number', serverSide: 'number' },
-      treeLight: { clientSide: 'number', serverSide: 'number' },
-      nextTreeLight: { clientSide: 'number', serverSide: 'number' },
-      nextTreeLevel: { clientSide: 'number', serverSide: 'number' },
-      imageLimit: { clientSide: 'number', serverSide: 'number' },
-      storageLimit: { clientSide: 'number', serverSide: 'number' },
-      managerLimit: { clientSide: 'number', serverSide: 'number' },
+      region: { clientSide: 'number' },
+      rank: { clientSide: 'number' },
+      volunteerCount: { clientSide: 'number' },
+      followCount: { clientSide: 'number' },
+      collectCount: { clientSide: 'number' },
+      villageType: { clientSide: 'number' },
+      altitude: { clientSide: 'number' },
+      longitude: { clientSide: 'number' },
+      latitude: { clientSide: 'number' },
+      age: { clientSide: 'number' },
+      area: { clientSide: 'number' },
+      villageArea: { clientSide: 'number' },
+      traditionalBuildings: { clientSide: 'number' },
+      ichLevel: { clientSide: 'number' },
+      historyLevel: { clientSide: 'number' },
+      touristLevel: { clientSide: 'number' },
+      isFeaturedVillage: { clientSide: 'number' },
+      registeredPopulationYear: { clientSide: 'number' },
+      registeredPopulation: { clientSide: 'number' },
+      permanentPopulationYear: { clientSide: 'number' },
+      permanentPopulation: { clientSide: 'number' },
+      personalAnnualIncomeYear: { clientSide: 'number' },
+      villageAnnualIncomeYear: { clientSide: 'number' },
+      points: { clientSide: 'number' },
+      light: { clientSide: 'number' },
+      lightTotal: { clientSide: 'number' },
+      fruitOutput: { clientSide: 'number' },
+      fruitRemain: { clientSide: 'number' },
+      fruitToday: { clientSide: 'number' },
+      level: { clientSide: 'number' },
+      weight: { clientSide: 'number' },
+      vipLevel: { clientSide: 'number' },
+      overviewId: { clientSide: 'number' },
+      myOverviewId: { clientSide: 'number' },
+      treeLight: { clientSide: 'number' },
+      nextTreeLight: { clientSide: 'number' },
+      nextTreeLevel: { clientSide: 'number' },
+      imageLimit: { clientSide: 'number' },
+      storageLimit: { clientSide: 'number' },
+      managerLimit: { clientSide: 'number' },
+      treeImageAnimProps: { clientSide: 'json' },
     }
     this._convertKeyType = (key, direction) => {
       if (key.endsWith('At'))
@@ -245,6 +246,8 @@ export class VillageListItem extends DataModel<VillageListItem> {
   treeImage = '';
   /** 当前等级所需乡源光 */
   treeLight = 0;
+  /** 树动画属性 */
+  treeImageAnimProps ?: VillageTreeAnimProps;
   /** 下一级等级树名称 */
   nextTreeName = '';
   /** 下一级所需乡源光 */
@@ -259,6 +262,16 @@ export class VillageListItem extends DataModel<VillageListItem> {
   managerLimit = 0;
 }
 
+export interface VillageTreeAnimProps {
+  width: number,
+  height: number,
+  frames: number[][],
+  framerate: number,
+  animations: Record<string, {
+    frames: number[],
+  }>,
+}
+
 export class PostMessage extends DataModel<PostMessage> {
   constructor() {
     super(PostMessage, "微信贴图");

+ 1 - 1
src/components/canvas/MiniRender.ts

@@ -495,7 +495,7 @@ export namespace MiniRender {
       super();
       if (config) Object.assign(this, config);
       this._resolvedImages = new Array(this.images.length).fill(null);
-      if (this.currentAnimation && !this.animations[this.currentAnimation]) {
+      if (this.currentAnimation && this.animations && !this.animations?.[this.currentAnimation]) {
         // 若未配置 animations,则允许把 currentAnimation 当成 “默认帧序列” 的别名
         this.animations[this.currentAnimation] = { frames: [0] };
       }

+ 4 - 5
src/pages/home/index.vue

@@ -411,11 +411,10 @@ async function loadInfo() {
 
   if (villageStore.myFollowVillages.length > 0) {
     const currentVillage = villageStore.loadCurrentVillage();
-    if (currentVillage) {
-      villageStore.setCurrentVillage(villageStore.myFollowVillages.find(p => p.id === currentVillage) as VillageListItem || villageStore.myFollowVillages[0]);
-    } else {
-      villageStore.setCurrentVillage(villageStore.myFollowVillages[0] as VillageListItem);
-    }
+    const id = (villageStore.myFollowVillages.find(p => p.id === currentVillage) as VillageListItem || villageStore.myFollowVillages[0]).id;
+    const res = await LightVillageApi.getVillageDetails(id)
+    console.log('加载我的关注村庄', res);
+    villageStore.setCurrentVillage(res);
   }
 }
 

+ 62 - 52
src/pages/home/village/components/VillageTree.vue

@@ -16,19 +16,32 @@
 </template>
 
 <script lang="ts" setup>
+import type { VillageTreeAnimProps } from '@/api/light/LightVillageApi';
 import { MiniRender } from '@/components/canvas/MiniRender';
 import { UniWeappRender } from '@/components/canvas/UniWeappRender';
 import { RandomUtils } from '@imengyu/imengyu-utils';
-import { getCurrentInstance, onBeforeUnmount, onMounted } from 'vue';
+import { getCurrentInstance, onBeforeUnmount, onMounted, watch } from 'vue';
 
-let currentText : MiniRender.Text | null = null;
-let treeMain : MiniRender.Sprite | null = null;
-const todoTreeText = '现在没有树的动画,树是静态的。';
+let treeMain : MiniRender.AnimateSprite | null = null;
 
+const props = withDefaults(defineProps<{
+  treeImage?: string;
+  treeAnimProps?: VillageTreeAnimProps;
+  fruitImage?: string;
+  treeName?: string;
+  backgroundImage?: string;
+}>(), {
+  treeName: '',
+  treeImage: '',
+  fruitImage: 'https://xy.wenlvti.net/app_static/images/home/tree/AnimFruit.png',
+  backgroundImage: 'https://xy.wenlvti.net/app_static/images/village/TreeTestBg.jpg',
+});
 const emit = defineEmits<{
   (e: 'fruitPick'): void;
 }>();
 
+
+
 const HEIGHT = 260;
 const instance = getCurrentInstance();
 const systemInfo = uni.getWindowInfo();
@@ -41,35 +54,33 @@ const render = new MiniRender.Scene(
   }, 
   async function() {
     const bg = new MiniRender.Sprite();
-    bg.src = 'https://xy.wenlvti.net/app_static/images/village/TreeTestBg.jpg';
+    bg.src = props.backgroundImage;
     bg.x = 0;
     bg.y = 0;
     bg.width = systemInfo.windowWidth;
     bg.height = HEIGHT;
+    render.root.add(bg);
 
-    const text = new MiniRender.Text(todoTreeText, {
-      fontSize: 20,
-      color: '#000',
-      align: 'center',
-      wrap: 'wrap',
-      maxWidth: this.precentXToPixel(0.8),
+    console.log('treeAnimProps', props.treeAnimProps);
+    if (!props.treeAnimProps)
+      return;
+
+
+    treeMain = new MiniRender.AnimateSprite({
+      framerate: props.treeAnimProps.framerate || 7,
+      images: [ props.treeImage ],
+      frames: props.treeAnimProps.frames as MiniRender.AnimateSpriteFrame[] || [],
+      animations: props.treeAnimProps.animations,
+      playOnce: false,
+      currentAnimation: 'default',
     });
-    text.x = this.precentXToPixel(0.2);
-    text.y = this.precentYToPixel(0.12);
-    currentText = text;
-
-    treeMain = new MiniRender.Sprite();
-    treeMain.src = 'https://xy.wenlvti.net/app_static/images/home/tree/Level4.png';
-    treeMain.width = this.precentXToPixel(0.60);
-    treeMain.height = this.precentYToPixel(0.85);
+    treeMain.width = this.precentXToPixel(props.treeAnimProps.width || 0.60);
+    treeMain.height = this.precentYToPixel(props.treeAnimProps.height || 0.85);
     treeMain.x = (this.width - treeMain.width) / 2;
     treeMain.y = (this.height - treeMain.height);
     treeMain.interactive = true;
 
-    render.root
-      .add(bg)
-      .add(text)
-      .add(treeMain)
+    render.root.add(treeMain)
   },
   (dtMs: number) => {
 
@@ -82,7 +93,7 @@ function createFruit() {
   
   const fruit = new MiniRender.AnimateSprite({
     framerate: 7,
-    images: ['https://xy.wenlvti.net/app_static/images/home/tree/AnimFruit.png'],
+    images: [props.fruitImage],
     frames: [
       [0, 0, 30, 30],
       [30, 0, 30, 30],
@@ -95,7 +106,7 @@ function createFruit() {
       default: { frames: [0, 1, 2, 3, 4, 5] },
     },
     playOnce: false,
-    currentAnimation: 'walk',
+    currentAnimation: 'default',
   });
   fruit.play();
   //在树的区域内创建水果
@@ -137,39 +148,38 @@ function removeAllFruit() {
     render.root.remove(fruit);
   });
 }
-
 function playStateAnimation(state: 'collect' | 'water' | 'fertilize' | 'task' | 'bless' | 'exchange') {
   //TODO: 播放动画
-  if (currentText) {
-    switch (state) {
-      case 'collect':
-        currentText.text = '拾果状态' + todoTreeText;
-        removeAllFruit();
-        break;
-      case 'water':
-        currentText.text = '浇水状态' + todoTreeText;
-        break;
-      case 'fertilize':
-        currentText.text = '施肥状态' + todoTreeText;
-        break;
-      case 'task':
-        currentText.text = '任务状态' + todoTreeText;
-        break;
-      case 'bless':
-        currentText.text = '赐福状态' + todoTreeText;
-        break;
-      case 'exchange':
-        currentText.text = '兑换状态' + todoTreeText;
-        break;
-    }
+  switch (state) {
+    case 'collect':
+      removeAllFruit();
+      break;
+    case 'water':
+      break;
+    case 'fertilize':
+      break;
+    case 'task':
+      break;
+    case 'bless':
+      break;
+    case 'exchange':
+      break;
   }
 }
 
+function reload() {
+  render.root.removeAll();
+  render.init();
+}
+
+watch(() => props.treeImage, () => {
+  if (!treeMain)  
+    return;
+  reload();
+});
+
 defineExpose({
-  reload: () => {
-    render.root.removeAll();
-    render.init();
-  },
+  reload,
   createFruits,
   playStateAnimation,
 });

+ 1 - 1
src/pages/home/village/index.vue

@@ -150,7 +150,7 @@ onMounted(async () => {
     });
   }
   if (isDevEnv) {
-    //tab.value = 'tree';
+    tab.value = 'tree';
   }
   await waitTimeOut(1000);
   if (villageStore.currentVillage) {

+ 32 - 8
src/pages/home/village/introd/tree.vue

@@ -1,17 +1,20 @@
 <template>
-  <FlexCol>
+  <FlexCol v-if="currentVillage">
     <FlexCol :margin="[10,0,0,0]">
       <Text textAlign="center" text="一人添果,全村增光;乡源树茂,故土名扬" fontConfig="primaryTitle" fontSize="35rpx"  />
     </FlexCol>
     <VillageTree 
       ref="villageTreeRef"
+      :treeImage="currentVillage.treeImage"
+      :treeName="currentVillage.treeName"
+      :treeAnimProps="currentVillage.treeImageAnimProps"
       @fruitPick="handlePick"
     />
     <FlexCol :padding="30">
       <FlexCol>
         <FlexRow center>
           <Progress 
-            :value="60" 
+            :value="treeProgress" 
             :backgroundStyle="{
               background: 'linear-gradient(to bottom, #280502, #a44e17)',
             }"
@@ -24,17 +27,16 @@
               overflow: 'hidden',
             }"
             :width="300" 
-            :height="30" 
-
+            :height="30"
           />
         </FlexRow>
         <Height height="space.lg" />
         <FlexRow center gap="gap.md">
           <Icon icon="https://xy.wenlvti.net/app_static/images/village/IconLight.png" :size="50" />
-          <Text text="乡源光 30%" fontConfig="contentText" fontSize="30rpx" color="#E79412" />
+          <Text :text="`乡源光 ${currentVillage?.lightTotal || 0} ${currentVillage.treeName}`" fontConfig="contentText" fontSize="30rpx" color="#E79412" />
         </FlexRow>
         <Height height="space.md" />
-        <Text textAlign="center" text="再浇水5次 可升级" fontConfig="secondText" />
+        <Text textAlign="center" :text="`还差 ${treeNextLevelLight} 乡源光即可升级至 ${currentVillage?.nextTreeName || ''}`" fontConfig="secondText" />
       </FlexCol>
 
       <Height height="space.xl" />
@@ -138,7 +140,7 @@
 </template>
 
 <script setup lang="ts">
-import { onBeforeMount, onMounted, ref, watch } from 'vue';
+import { computed, onBeforeMount, onMounted, ref, watch } from 'vue';
 import { useVillageStore } from '@/store/village';
 import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
 import { useRequireLogin } from '@/common/composeabe/RequireLogin';
@@ -233,6 +235,22 @@ const blessingInfoLoader = useSimpleDataLoader(async () => {
   return res.list;
 });
 
+const currentVillage = computed(() => {
+  return villageStore.currentVillage;
+});
+const treeProgress = computed(() => {
+  if (!villageStore.currentVillage)
+    return 0;
+  const v = villageStore.currentVillage;
+  return Math.floor(v.lightTotal - v.treeLight) / (v.nextTreeLight - v.treeLight) * 100;
+});
+const treeNextLevelLight = computed(() => {
+  if (!villageStore.currentVillage)
+    return 0;
+  const v = villageStore.currentVillage;
+  return v.nextTreeLight - v.treeLight;
+});
+
 function handleBuyBless(bless: BlessPackageItem) {
   currentBless.value = bless;
   blessBuyDialogRef.value?.show();
@@ -332,6 +350,7 @@ async function handlePickOrWaterOrFertilize(action: 'pick' | 'water' | 'fertiliz
         villageTreeRef.value?.playStateAnimation('fertilize');
         break;
     }
+    refreshVillageTreeInfo();
     uni.hideLoading();
     uni.showToast({
       title: res,
@@ -356,6 +375,9 @@ async function getFruits() {
     villageTreeRef.value?.createFruits(res.fruitRemain);
   }
 }
+async function refreshVillageTreeInfo() {
+  await villageStore.reloadVillageInfo();
+}
 
 const refreshFruitTimer = new SimpleTimer(undefined, () => getFruits(), 15000);
 
@@ -366,7 +388,9 @@ onBeforeMount(() => {
   refreshFruitTimer.stop();
 });
 defineExpose({
-  onPageBack: (name: string, data: Record<string, unknown>) => {;
+  onPageBack: (name: string, data: Record<string, unknown>) => {
+    
+
   },
 });
 </script>

+ 0 - 1
src/store/village.ts

@@ -1,7 +1,6 @@
 import { ref } from 'vue'
 import { defineStore } from 'pinia'
 import type { VillageListItem } from '@/api/light/LightVillageApi';
-import type { VolunteerInfo } from '@/api/inhert/VillageApi';
 import FollowVillageApi from '@/api/light/FollowVillageApi';
 import LightVillageApi from '@/api/light/LightVillageApi';
 

+ 111 - 0
tools/gif_to_spritesheet.py

@@ -0,0 +1,111 @@
+"""
+将 GIF 转换为精灵图(Spritesheet),并输出 AnimateSprite 的 frames 定义。
+
+用法:
+  python gif_to_spritesheet.py input.gif [--cols 8] [--scale 50] [--output spritesheet.png]
+
+参数:
+  --scale   缩放百分比,如 50 表示缩小到 50%,200 表示放大到 200%(默认 100)
+  --cols    每行帧数(默认 8)
+  --output  输出文件路径
+
+输出:
+  1. 精灵图 PNG 文件
+  2. 控制台打印 AnimateSprite 的 frames 和 animations 配置(基于缩放后的尺寸)
+"""
+
+import argparse
+import json
+from PIL import Image
+
+
+def extract_frames(gif_path: str) -> list[Image.Image]:
+    gif = Image.open(gif_path)
+    frames = []
+    try:
+        while True:
+            frames.append(gif.copy().convert('RGBA'))
+            gif.seek(gif.tell() + 1)
+    except EOFError:
+        pass
+    return frames
+
+
+def scale_frames(frames: list[Image.Image], scale_percent: float) -> list[Image.Image]:
+    if scale_percent == 100:
+        return frames
+    factor = scale_percent / 100.0
+    ow, oh = frames[0].size
+    nw, nh = max(1, round(ow * factor)), max(1, round(oh * factor))
+    resample = Image.LANCZOS if factor < 1 else Image.LANCZOS
+    return [f.resize((nw, nh), resample) for f in frames]
+
+
+def build_spritesheet(frames: list[Image.Image], cols: int) -> Image.Image:
+    w, h = frames[0].size
+    rows = (len(frames) + cols - 1) // cols
+    sheet = Image.new('RGBA', (w * cols, h * rows), (0, 0, 0, 0))
+    for i, frame in enumerate(frames):
+        sheet.paste(frame, ((i % cols) * w, (i // cols) * h))
+    return sheet
+
+
+def generate_animate_sprite_config(frame_count: int, frame_w: int, frame_h: int, cols: int) -> dict:
+    frames = []
+    for i in range(frame_count):
+        x = (i % cols) * frame_w
+        y = (i // cols) * frame_h
+        frames.append([x, y, frame_w, frame_h])
+    return {
+        "frames": frames,
+        "animations": {
+            "default": {"frames": list(range(frame_count))},
+        },
+    }
+
+
+def main():
+    parser = argparse.ArgumentParser(description='GIF 转精灵图 + AnimateSprite 配置生成')
+    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('--output', '-o', default=None, help='输出精灵图路径 (默认 <input>_spritesheet.png)')
+    args = parser.parse_args()
+
+    output_path = args.output or args.input.rsplit('.', 1)[0] + '_spritesheet.png'
+
+    frames = extract_frames(args.input)
+    orig_w, orig_h = frames[0].size
+    print(f'提取到 {len(frames)} 帧, 原始单帧尺寸: {orig_w}x{orig_h}')
+
+    if args.scale != 100:
+        frames = scale_frames(frames, args.scale)
+        sw, sh = frames[0].size
+        print(f'缩放 {args.scale}%: {orig_w}x{orig_h} -> {sw}x{sh}')
+
+    sheet = build_spritesheet(frames, args.cols)
+    sheet.save(output_path)
+
+    fw, fh = frames[0].size
+    print(f'精灵图已保存: {output_path} ({sheet.size[0]}x{sheet.size[1]})')
+
+    config = generate_animate_sprite_config(len(frames), fw, fh, args.cols)
+
+    print('\n// AnimateSprite 配置:')
+    print(f'// 精灵图尺寸: {sheet.size[0]}x{sheet.size[1]}, 单帧: {fw}x{fh}, 缩放: {args.scale}%')
+    print(f'const sprite = new MiniRender.AnimateSprite({{')
+    print(f'  framerate: 7,')
+    print(f'  images: [\'<精灵图URL>\'],')
+    print(f'  frames: {json.dumps(config["frames"])},')
+    print(f'  animations: {{')
+    print(f'    default: {{ frames: {json.dumps(config["animations"]["default"]["frames"])} }},')
+    print(f'  }},')
+    print(f'  playOnce: false,')
+    print(f'  currentAnimation: \'default\',')
+    print(f'}});')
+    print(f'\n// sprite.width = {fw};')
+    print(f'// sprite.height = {fh};')
+
+
+if __name__ == '__main__':
+    main()

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 9 - 0
tools/tree1.json