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

实战案例 16:NFT 合成与拆解系统

目标: 构建材料合成系统——销毁多个低级 NFT 合成一个高级 NFT(概率性),也支持高级 NFT 拆解为材料;利用链上随机数确保结果公平。


状态:教学示例。正文讲解合成/拆解与随机数接入,完整目录以 book/src/code/example-16/ 为准。

对应代码目录

最小调用链

用户选择材料 -> 合约读取随机数 -> 执行合成/失败返还 -> 发事件 -> 前端刷新结果

需求分析

场景: 你设计了三层装备体系:

  • 材料碎片(Fragment):普通,随机掉落
  • 精炼组件(Component):3 个碎片 → 60% 概率合成
  • 传世神器(Artifact):3 个精炼组件 → 30% 概率合成,失败返回 1 个组件

合约

module crafting::forge;

use sui::object::{Self, UID, ID};
use sui::random::{Self, Random};
use sui::transfer;
use sui::event;
use std::string::{Self, String, utf8};

// ── 常量 ──────────────────────────────────────────────────

const TIER_FRAGMENT: u8 = 0;
const TIER_COMPONENT: u8 = 1;
const TIER_ARTIFACT: u8 = 2;

// 合成成功率(BPS)
const FRAGMENT_TO_COMPONENT_BPS: u64 = 6_000; // 60%
const COMPONENT_TO_ARTIFACT_BPS: u64 = 3_000; // 30%

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

public struct ForgeItem has key, store {
    id: UID,
    tier: u8,
    name: String,
    image_url: String,
    power: u64,    // 属性值(越高级越强)
}

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

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

public struct CraftAttempted has copy, drop {
    crafter: address,
    input_tier: u8,
    success: bool,
    result_tier: u8,
}

public struct ItemDisassembled has copy, drop {
    crafter: address,
    from_tier: u8,
    fragments_returned: u64,
}

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

fun init(ctx: &mut TxContext) {
    transfer::public_transfer(ForgeAdminCap { id: object::new(ctx) }, ctx.sender());
}

/// 铸造基础碎片(Admin only,比如任务奖励)
public fun mint_fragment(
    _cap: &ForgeAdminCap,
    recipient: address,
    ctx: &mut TxContext,
) {
    let item = ForgeItem {
        id: object::new(ctx),
        tier: TIER_FRAGMENT,
        name: utf8(b"Plasma Fragment"),
        image_url: utf8(b"https://assets.example.com/fragment.png"),
        power: 10,
    };
    transfer::public_transfer(item, recipient);
}

// ── 合成:3 个低级 → 1 个高级(带随机成功率)────────────

public fun craft(
    input1: ForgeItem,
    input2: ForgeItem,
    input3: ForgeItem,
    random: &Random,
    ctx: &mut TxContext,
) {
    // 三个输入必须同一阶段
    assert!(input1.tier == input2.tier && input2.tier == input3.tier, EMismatchedTier);
    let input_tier = input1.tier;
    assert!(input_tier < TIER_ARTIFACT, EMaxTierReached);

    let target_tier = input_tier + 1;

    // 获取链上随机数(0-9999)
    let mut rng = random::new_generator(random, ctx);
    let roll = rng.generate_u64() % 10_000;

    let success_threshold = if target_tier == TIER_COMPONENT {
        FRAGMENT_TO_COMPONENT_BPS
    } else {
        COMPONENT_TO_ARTIFACT_BPS
    };

    // 无论成功与否,都销毁三个输入
    let ForgeItem { id: id1, .. } = input1;
    let ForgeItem { id: id2, .. } = input2;
    let ForgeItem { id: id3, .. } = input3;
    id1.delete(); id2.delete(); id3.delete();

    let success = roll < success_threshold;

    if success {
        let (name, image_url, power) = get_tier_info(target_tier);
        let result = ForgeItem {
            id: object::new(ctx),
            tier: target_tier,
            name,
            image_url,
            power,
        };
        transfer::public_transfer(result, ctx.sender());
    } else if target_tier == TIER_ARTIFACT {
        // 合成神器失败时,安慰奖:返还 1 个精炼组件
        let (name, image_url, power) = get_tier_info(TIER_COMPONENT);
        let consolation = ForgeItem {
            id: object::new(ctx),
            tier: TIER_COMPONENT,
            name,
            image_url,
            power,
        };
        transfer::public_transfer(consolation, ctx.sender());
    };
    // 合成组件失败时不返还任何东西(60% 成功率,风险在于玩家)

    event::emit(CraftAttempted {
        crafter: ctx.sender(),
        input_tier,
        success,
        result_tier: if success { target_tier } else { input_tier },
    });
}

// ── 拆解:1 个高级 → 多个低级 ────────────────────────────

public fun disassemble(
    item: ForgeItem,
    ctx: &mut TxContext,
) {
    assert!(item.tier > TIER_FRAGMENT, ECannotDisassembleFragment);

    let target_tier = item.tier - 1;
    let fragments_to_return = 2u64; // 拆解只返还 2 个(有损耗)
    let item_tier = item.tier;

    let ForgeItem { id, .. } = item;
    id.delete();

    let (name, image_url, power) = get_tier_info(target_tier);
    let mut i = 0;
    while (i < fragments_to_return) {
        let fragment = ForgeItem {
            id: object::new(ctx),
            tier: target_tier,
            name,
            image_url,
            power,
        };
        transfer::public_transfer(fragment, ctx.sender());
        i = i + 1;
    };

    event::emit(ItemDisassembled {
        crafter: ctx.sender(),
        from_tier: item_tier,
        fragments_returned: fragments_to_return,
    });
}

fun get_tier_info(tier: u8): (String, String, u64) {
    if tier == TIER_FRAGMENT {
        (utf8(b"Plasma Fragment"), utf8(b"https://assets.example.com/fragment.png"), 10)
    } else if tier == TIER_COMPONENT {
        (utf8(b"Refined Component"), utf8(b"https://assets.example.com/component.png"), 100)
    } else {
        (utf8(b"Ancient Artifact"), utf8(b"https://assets.example.com/artifact.png"), 1000)
    }
}

const EMismatchedTier: u64 = 0;
const EMaxTierReached: u64 = 1;
const ECannotDisassembleFragment: u64 = 2;

dApp(铸造台界面)

// ForgingStation.tsx
import { useState } from 'react'
import { useCurrentClient, useCurrentAccount } from '@mysten/dapp-kit-react'
import { useQuery } from '@tanstack/react-query'
import { Transaction } from '@mysten/sui/transactions'
import { useDAppKit } from '@mysten/dapp-kit-react'

const CRAFTING_PKG = "0x_CRAFTING_PACKAGE_"
const TIER_NAMES = ['💎 碎片', '⚙️ 精炼组件', '🌟 传世神器']
const CRAFT_RATES = ['60%', '30%', '—']

export function ForgingStation() {
  const client = useCurrentClient()
  const dAppKit = useDAppKit()
  const account = useCurrentAccount()
  const [selected, setSelected] = useState<string[]>([])
  const [status, setStatus] = useState('')
  const [lastCraft, setLastCraft] = useState<{success: boolean; tier: string} | null>(null)

  const { data: userItems, refetch } = useQuery({
    queryKey: ['forge-items', account?.address],
    queryFn: async () => {
      if (!account) return []
      const objs = await client.getOwnedObjects({
        owner: account.address,
        filter: { StructType: `${CRAFTING_PKG}::forge::ForgeItem` },
        options: { showContent: true },
      })
      return objs.data.map(obj => ({
        id: obj.data!.objectId,
        tier: Number((obj.data!.content as any).fields.tier),
        name: (obj.data!.content as any).fields.name,
        power: (obj.data!.content as any).fields.power,
      }))
    },
    enabled: !!account,
  })

  const toggleSelect = (id: string) => {
    setSelected(prev =>
      prev.includes(id) ? prev.filter(i => i !== id) : prev.length < 3 ? [...prev, id] : prev
    )
  }

  const handleCraft = async () => {
    if (selected.length !== 3) return
    const tx = new Transaction()
    tx.moveCall({
      target: `${CRAFTING_PKG}::forge::craft`,
      arguments: [
        tx.object(selected[0]),
        tx.object(selected[1]),
        tx.object(selected[2]),
        tx.object('0x8'), // Random 系统对象
      ],
    })
    try {
      setStatus('⏳ 合成中(链上随机数判定)...')
      const result = await dAppKit.signAndExecuteTransaction({ transaction: tx })
      // 从事件读取合成结果
      const craftEvent = result.events?.find(e => e.type.includes('CraftAttempted'))
      if (craftEvent) {
        const { success, result_tier } = craftEvent.parsedJson as any
        setLastCraft({ success, tier: TIER_NAMES[Number(result_tier)] })
        setStatus(success ? `✅ 合成成功!获得 ${TIER_NAMES[Number(result_tier)]}` : '❌ 合成失败')
      }
      setSelected([])
      refetch()
    } catch (e: any) { setStatus(`❌ ${e.message}`) }
  }

  const selectedTier = selected.length > 0 && userItems
    ? userItems.find(i => i.id === selected[0])?.tier
    : null

  return (
    <div className="forging-station">
      <h1>⚒ 神秘铸造台</h1>

      {lastCraft && (
        <div className={`craft-result ${lastCraft.success ? 'success' : 'fail'}`}>
          {lastCraft.success ? '✨ 合成成功!' : '💔 合成失败'} → {lastCraft.tier}
        </div>
      )}

      <div className="craft-info">
        <div>碎片 × 3 → 精炼组件(成功率 {CRAFT_RATES[0]})</div>
        <div>精炼组件 × 3 → 传世神器(成功率 {CRAFT_RATES[1]})</div>
      </div>

      <h3>选择 3 件同阶物品进行合成</h3>
      <div className="items-grid">
        {userItems?.map(item => (
          <div
            key={item.id}
            className={`item-slot ${selected.includes(item.id) ? 'selected' : ''}`}
            onClick={() => toggleSelect(item.id)}
          >
            <div className="tier-badge">{TIER_NAMES[item.tier]}</div>
            <div className="power">⚡ {item.power}</div>
          </div>
        ))}
      </div>

      <button
        className="craft-btn"
        disabled={selected.length !== 3}
        onClick={handleCraft}
      >
        🔥 开始合成({selected.length}/3 已选)
      </button>

      {status && <p className="status">{status}</p>}
    </div>
  )
}

📚 关联文档