Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

实战案例 6:动态 NFT 装备系统(可进化的飞船武器)

目标: 创建一套飞船武器 NFT,其属性随游戏战斗结果自动升级;利用 Sui Display 标准确保 NFT 在所有钱包和市场中实时显示最新状态。


状态:教学示例。正文聚焦动态 NFT 和 Display 更新,完整目录以 book/src/code/example-06/ 为准。

对应代码目录

最小调用链

玩家持有武器 NFT -> 击杀事件累加 -> 达到阈值升级 -> Display 元数据更新 -> 钱包/市场显示新外观

需求分析

场景: 你设计了一款“成长型武器“系统——玩家获得一把 PlasmaRifle,初始是一把普通武器,随着每次击杀积累,自动升级外观和属性:

  • 初级(0-9 击杀):Plasma Rifle Mk.1,基础伤害
  • 🔵 精英(10-49 击杀):Plasma Rifle Mk.2,图片变为精英版本,伤害+30%
  • 🟡 传奇(50+ 击杀):Plasma Rifle Mk.3 “Inferno”,图片变为传奇版本,特殊效果

第一部分:NFT 合约

module dynamic_nft::plasma_rifle;

use sui::object::{Self, UID};
use sui::display;
use sui::package;
use sui::transfer;
use sui::event;
use std::string::{Self, String, utf8};

// ── 一次性见证 ─────────────────────────────────────────────

public struct PLASMA_RIFLE has drop {}

// ── 武器等级常量 ───────────────────────────────────────────

const TIER_BASIC: u8 = 1;
const TIER_ELITE: u8 = 2;
const TIER_LEGENDARY: u8 = 3;

const KILLS_FOR_ELITE: u64 = 10;
const KILLS_FOR_LEGENDARY: u64 = 50;

// ── 数据结构 ───────────────────────────────────────────────

public struct PlasmaRifle has key, store {
    id: UID,
    name: String,
    tier: u8,
    kills: u64,
    damage_bonus_pct: u64,   // 伤害加成(百分比)
    image_url: String,
    description: String,
    owner_history: u64,      // 历史流通次数
}

public struct ForgeAdminCap has key, store {
    id: UID,
}

// ── 事件 ──────────────────────────────────────────────────

public struct RifleEvolved has copy, drop {
    rifle_id: ID,
    from_tier: u8,
    to_tier: u8,
    total_kills: u64,
}

// ── 初始化 ────────────────────────────────────────────────

fun init(witness: PLASMA_RIFLE, ctx: &mut TxContext) {
    let publisher = package::claim(witness, ctx);

    let keys = vector[
        utf8(b"name"),
        utf8(b"description"),
        utf8(b"image_url"),
        utf8(b"attributes"),
        utf8(b"project_url"),
    ];

    let values = vector[
        utf8(b"{name}"),
        utf8(b"{description}"),
        utf8(b"{image_url}"),
        // attributes 拼接多个字段
        utf8(b"[{\"trait_type\":\"Tier\",\"value\":\"{tier}\"},{\"trait_type\":\"Kills\",\"value\":\"{kills}\"},{\"trait_type\":\"Damage Bonus\",\"value\":\"{damage_bonus_pct}%\"}]"),
        utf8(b"https://evefrontier.com/weapons"),
    ];

    let mut display = display::new_with_fields<PlasmaRifle>(
        &publisher, keys, values, ctx,
    );
    display::update_version(&mut display);

    let admin_cap = ForgeAdminCap { id: object::new(ctx) };

    transfer::public_transfer(publisher, ctx.sender());
    transfer::public_freeze_object(display);
    transfer::public_transfer(admin_cap, ctx.sender());
}

// ── 铸造初始武器 ──────────────────────────────────────────

public fun forge_rifle(
    _admin: &ForgeAdminCap,
    recipient: address,
    ctx: &mut TxContext,
) {
    let rifle = PlasmaRifle {
        id: object::new(ctx),
        name: utf8(b"Plasma Rifle Mk.1"),
        tier: TIER_BASIC,
        kills: 0,
        damage_bonus_pct: 0,
        image_url: utf8(b"https://assets.example.com/weapons/plasma_mk1.png"),
        description: utf8(b"A standard-issue plasma rifle. Prove yourself in combat."),
        owner_history: 0,
    };

    transfer::public_transfer(rifle, recipient);
}

// ── 记录击杀(炮塔扩展调用此函数)────────────────────────

public fun record_kill(
    rifle: &mut PlasmaRifle,
    ctx: &TxContext,
) {
    rifle.kills = rifle.kills + 1;
    check_and_evolve(rifle);
}

fun check_and_evolve(rifle: &mut PlasmaRifle) {
    let old_tier = rifle.tier;

    if rifle.kills >= KILLS_FOR_LEGENDARY && rifle.tier < TIER_LEGENDARY {
        rifle.tier = TIER_LEGENDARY;
        rifle.name = utf8(b"Plasma Rifle Mk.3 \"Inferno\"");
        rifle.damage_bonus_pct = 60;
        rifle.image_url = utf8(b"https://assets.example.com/weapons/plasma_legendary.png");
        rifle.description = utf8(b"This weapon has bathed in the fires of a thousand battles. Its plasma burns with legendary fury.");
    } else if rifle.kills >= KILLS_FOR_ELITE && rifle.tier < TIER_ELITE {
        rifle.tier = TIER_ELITE;
        rifle.name = utf8(b"Plasma Rifle Mk.2");
        rifle.damage_bonus_pct = 30;
        rifle.image_url = utf8(b"https://assets.example.com/weapons/plasma_mk2.png");
        rifle.description = utf8(b"Battle-hardened and upgraded. The plasma cells burn hotter than standard.");
    };

    if old_tier != rifle.tier {
        event::emit(RifleEvolved {
            rifle_id: object::id(rifle),
            from_tier: old_tier,
            to_tier: rifle.tier,
            total_kills: rifle.kills,
        });
    }
}

// ── 读取函数 ──────────────────────────────────────────────

public fun get_tier(rifle: &PlasmaRifle): u8 { rifle.tier }
public fun get_kills(rifle: &PlasmaRifle): u64 { rifle.kills }
public fun get_damage_bonus(rifle: &PlasmaRifle): u64 { rifle.damage_bonus_pct }

// ── 转让追踪(可选) ─────────────────────────────────────

// 如果使用 TransferPolicy,可以追踪转让次数
// 此处简化为通过事件监听实现

第二部分:炮塔扩展 — 战斗结果上报武器

module dynamic_nft::turret_combat;

use dynamic_nft::plasma_rifle::{Self, PlasmaRifle};
use world::turret::{Self, Turret};
use world::character::Character;

public struct CombatAuth has drop {}

/// 炮塔击杀事件(炮塔扩展调用)
public fun on_kill(
    turret: &Turret,
    killer: &Character,
    weapon: &mut PlasmaRifle,       // 玩家使用的武器
    ctx: &TxContext,
) {
    // 验证是合法的炮塔扩展调用(需要 CombatAuth)
    turret::verify_extension(turret, CombatAuth {});

    // 记录击杀到武器
    plasma_rifle::record_kill(weapon, ctx);
}

第三部分:前端武器展示 dApp

// src/WeaponDisplay.tsx
import { useState, useEffect } from 'react'
import { useCurrentClient } from '@mysten/dapp-kit-react'
import { useRealtimeEvents } from './hooks/useRealtimeEvents'

const DYNAMIC_NFT_PKG = "0x_DYNAMIC_NFT_PACKAGE_"

interface RifleData {
  name: string
  tier: string
  kills: string
  damage_bonus_pct: string
  image_url: string
  description: string
}

const TIER_COLORS = {
  '1': '#9CA3AF',  // 灰色(普通)
  '2': '#3B82F6',  // 蓝色(精英)
  '3': '#F59E0B',  // 金色(传奇)
}

const TIER_LABELS = { '1': 'Basic', '2': 'Elite', '3': 'Legendary' }

export function WeaponDisplay({ rifleId }: { rifleId: string }) {
  const client = useCurrentClient()
  const [rifle, setRifle] = useState<RifleData | null>(null)
  const [justEvolved, setJustEvolved] = useState(false)

  const loadRifle = async () => {
    const obj = await client.getObject({
      id: rifleId,
      options: { showContent: true },
    })
    if (obj.data?.content?.dataType === 'moveObject') {
      setRifle(obj.data.content.fields as RifleData)
    }
  }

  useEffect(() => { loadRifle() }, [rifleId])

  // 监听进化事件
  const evolutions = useRealtimeEvents<{
    rifle_id: string; from_tier: string; to_tier: string; total_kills: string
  }>(`${DYNAMIC_NFT_PKG}::plasma_rifle::RifleEvolved`)

  useEffect(() => {
    const myEvolution = evolutions.find(e => e.rifle_id === rifleId)
    if (myEvolution) {
      setJustEvolved(true)
      loadRifle() // 重新加载最新数据
      setTimeout(() => setJustEvolved(false), 5000)
    }
  }, [evolutions])

  if (!rifle) return <div className="loading">加载武器数据...</div>

  const tierColor = TIER_COLORS[rifle.tier as keyof typeof TIER_COLORS]
  const tierLabel = TIER_LABELS[rifle.tier as keyof typeof TIER_LABELS]
  const killsForNextTier = rifle.tier === '1'
    ? 10 : rifle.tier === '2' ? 50 : null
  const progress = killsForNextTier
    ? Math.min(100, (Number(rifle.kills) / killsForNextTier) * 100) : 100

  return (
    <div className="weapon-card" style={{ borderColor: tierColor }}>
      {justEvolved && (
        <div className="evolution-banner">
          ✨ 武器已进化!
        </div>
      )}

      <div className="weapon-image-container">
        <img
          src={rifle.image_url}
          alt={rifle.name}
          className={`weapon-image tier-${rifle.tier}`}
        />
        <span className="tier-badge" style={{ background: tierColor }}>
          {tierLabel}
        </span>
      </div>

      <div className="weapon-info">
        <h2>{rifle.name}</h2>
        <p className="description">{rifle.description}</p>

        <div className="stats">
          <div className="stat">
            <span>⚔️ 击杀数</span>
            <strong>{rifle.kills}</strong>
          </div>
          <div className="stat">
            <span>💥 伤害加成</span>
            <strong>+{rifle.damage_bonus_pct}%</strong>
          </div>
        </div>

        {killsForNextTier && (
          <div className="evolution-progress">
            <span>进化进度:{rifle.kills} / {killsForNextTier} 击杀</span>
            <div className="progress-bar">
              <div
                className="progress-fill"
                style={{ width: `${progress}%`, background: tierColor }}
              />
            </div>
          </div>
        )}

        {!killsForNextTier && (
          <div className="max-tier-badge">👑 已达最高等级</div>
        )}
      </div>
    </div>
  )
}

🎯 完整回顾

合约层
├── plasma_rifle.move
│   ├── PlasmaRifle(NFT 对象,字段随战斗更新)
│   ├── Display(模板引用字段 → 钱包自动同步显示)
│   ├── forge_rifle()    ← Owner 铸造发放
│   ├── record_kill()    ← 炮塔合约调用
│   └── check_and_evolve() ← 内部:检查阈值,升级字段 + 发事件
│
└── turret_combat.move
    └── on_kill()         ← 炮塔击杀时调用武器升级

dApp 层
└── WeaponDisplay.tsx
    ├── 订阅 RifleEvolved 事件(一旦进化立即刷新)
    ├── 动态颜色主题(按等级)
    └── 进化进度条

🔧 扩展练习

  1. 武器磨损:每次使用降低 durability 字段,质量下降后伤害减少(需要修理)
  2. 特殊属性:传奇等级随机获得特殊词缀(用随机数 + 动态字段)
  3. 武器融合:两把 Elite 武器销毁 → 铸造一把 Legendary(材料消耗型升级)

📚 关联文档