From 0422f3ad54192e1a235884d5e7ec8730272582a0 Mon Sep 17 00:00:00 2001 From: OwenLu Date: Mon, 13 Jul 2026 22:07:46 +0800 Subject: [PATCH] feat(web): gem forge event page (/events/gem-forge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 黑客松(FutureMode)與 Game Night 共用的獨立活動頁: 與 AI 鍛造師對話,把描述鍛成一顆專屬寶石。 - src/lib/gemForge.ts:寶石 schema 與可抽換 ForgeEngine 介面, v1 為規則式四輪對話(出身/性格/守護/命名),元素關鍵字加權 判定、稀有度依描述豐富度、收藏純 localStorage 不打後端 - GemVisual:屬性驅動的 SVG 占位視覺(視覺產出方案留待後續, 整顆元件即抽換點) - GemCard:3:4 直式截圖友善卡面 - 頁面狀態機:入場 → 對話 → 鍛造動畫 → 成品 + 寶石匣收藏, 匿名體驗、不掛 Privy/UserContext,行動裝置優先 - docs/gem-forge-plan.html:活動頁企劃文件 Co-Authored-By: Claude Fable 5 --- docs/gem-forge-plan.html | 458 ++++++++++++++++++++++++++ src/components/gemforge/GemCard.tsx | 101 ++++++ src/components/gemforge/GemVisual.tsx | 62 ++++ src/lib/gemForge.ts | 287 ++++++++++++++++ src/pages/events/gem-forge.tsx | 404 +++++++++++++++++++++++ src/styles/globals.css | 169 ++++++++++ 6 files changed, 1481 insertions(+) create mode 100644 docs/gem-forge-plan.html create mode 100644 src/components/gemforge/GemCard.tsx create mode 100644 src/components/gemforge/GemVisual.tsx create mode 100644 src/lib/gemForge.ts create mode 100644 src/pages/events/gem-forge.tsx diff --git a/docs/gem-forge-plan.html b/docs/gem-forge-plan.html new file mode 100644 index 0000000..508a1e3 --- /dev/null +++ b/docs/gem-forge-plan.html @@ -0,0 +1,458 @@ + + + + + +Aurayale 寶石鍛造所 — 活動頁企劃 + + + +
+
+
Aurayale · 活動頁企劃
+

寶石鍛造所 — AI 對話生成寶石體驗頁

+
+ 版本 v1 + 2026-07-13 + 用途:FutureMode 黑客松 + Game Night 線下活動 +
+
+ +

在 Aurayale 主站之外新增一個獨立活動頁:使用者與 AI 對話,描述心中想像的寶石,AI 將對話轉化為一顆結構化的專屬寶石並產出「寶石卡」。同一套體驗同時服務黑客松評審 demo 與 Game Night 現場玩家 — 一魚雙吃。

+ +

01目標與定位

+

核心體驗是「用對話把想像鍛造成一顆寶石」。頁面完全獨立於主站帳號體系:不需登入、不上鏈、結果不存後端,將進場摩擦降到最低,換取 demo 流暢度與現場轉化率。

+
+
+ 黑客松 +

評審視角:創新敘事

+

賣點是「AI 生成式遊戲資產」的完整故事:

+
    +
  • 對話式生成流程本身就是 demo 素材
  • +
  • 結構化屬性輸出,展示可控的生成管線
  • +
  • 敘事上保留「未來接上 ERC-1155 鑄造」的路徑,說明為何此階段刻意不上鏈
  • +
+
+
+ Game Night +

玩家視角:快、好玩、能帶走

+
    +
  • 掃 QR code 即玩,零登入、零排隊摩擦
  • +
  • 幾輪對話內就能拿到成品,適合現場節奏
  • +
  • 寶石卡可截圖分享,成為社群傳播素材
  • +
  • 收藏存在裝置上(localStorage),離場後仍可回味
  • +
+
+
+ +

02核心體驗流程

+
    +
  1. + 入場 +

    簡短的世界觀引言(Aurayale 宇宙 × 鍛造寶石的儀式感),一顆 CTA 進入鍛造流程。

    +
  2. +
  3. + AI 對話引導 +

    AI 以鍛造師的角色提問,引導使用者描述寶石的元素、個性與故事(約 3–5 輪對話)。

    +
  4. +
  5. + 屬性結晶 +

    對話結果轉化為結構化寶石屬性:名稱、元素、稀有度、數值、風格描述(JSON schema 固定)。

    +
  6. +
  7. + 寶石卡產出 +

    前端依屬性渲染一張寶石卡。視覺產出方式暫定留空(先以屬性驅動的占位視覺呈現,後續再決定 AI 生圖或規則式美術)。

    +
  8. +
  9. + 結果與分享 +

    成品頁提供:加入「我的收藏」(localStorage)、重新鍛造、分享(截圖導向的卡面設計)。

    +
  10. +
+ +

03頁面架構

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
路由 / 區塊內容
/events/gem-forge活動主頁。單頁式體驗,依狀態切換:入場 → 對話 → 鍛造動畫 → 成品。行動裝置優先(Game Night 現場以手機為主),桌機同樣可用(黑客松 demo)。
入場區活動標題、世界觀引言、開始鍛造 CTA、我的收藏入口。
對話區聊天介面:AI 鍛造師訊息、使用者輸入、進度提示(第幾輪/還差什麼素材)。
成品區寶石卡(名稱、元素、稀有度、數值、故事文案)、收藏/重鍛/分享操作。
我的收藏本機收藏的寶石列表(localStorage),可重看每顆寶石的卡面。
+
+ +

04技術規格

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
面向決策
框架沿用現有 Next.js 15(Pages Router)+ React 19 + Tailwind CSS v4,新增獨立頁面,不掛入主站 Provider 需求(不使用 Privy / UserContext / AuraServer JWT)。
對話引擎定義可抽換的 ForgeEngine 介面(輸入對話歷史 → 輸出下一句提問或最終寶石 JSON)。第一版以規則式引導對話實作(依序詢問元素/個性/故事並映射屬性),介面預留之後接 LLM API 的空間。
寶石資料固定 schema:{ name, element, rarity, stats, story, styleHints }styleHints 為視覺產出保留欄位。
視覺產出暫定留空。先以屬性驅動的占位卡面(色彩/形狀由 element 與 rarity 決定)呈現,元件介面獨立成 GemVisual,之後可整顆替換。
儲存純前端:收藏與進行中對話存 localStorage,不新增任何後端 API。
帳號匿名體驗,無登入。
+
+ +

05範圍界定

+
+
+

✓ 本期範圍

+
    +
  • 活動頁完整體驗(入場 → 對話 → 成品 → 收藏)
  • +
  • 規則式對話引擎(介面預留 LLM 接點)
  • +
  • 屬性驅動的占位寶石卡
  • +
  • localStorage 收藏
  • +
  • 行動裝置優先的響應式版面
  • +
+
+
+

✗ 明確不做(本期)

+
    +
  • ERC-1155 上鏈鑄造
  • +
  • 後端儲存生成結果
  • +
  • Privy 登入 / 主站帳號整合
  • +
  • 視覺產出的最終方案(AI 生圖 vs. 規則式美術)
  • +
  • 與 Unity 遊戲本體的橋接
  • +
+
+
+
+ 未定項:寶石卡的視覺產出方式(AI 圖像生成或規則式美術拼接)與是否接真實 LLM 對話,皆已在架構上預留抽換點(GemVisual 元件、ForgeEngine 介面),待後續討論定案後替換,不影響本期交付。 +
+ +

06里程碑

+
+ + + + + + + + + + +
階段內容
M1 骨架頁面路由、狀態機(入場/對話/成品)、寶石 schema 與規則式對話引擎。
M2 體驗對話 UI、鍛造轉場、占位寶石卡、收藏功能。
M3 打磨行動裝置調校、分享導向的卡面設計、黑客松 demo 腳本演練。
賽後視需求接 LLM、決定視覺方案、評估上鏈與帳號整合(Game Night 加值)。
+
+ +
+ Aurayale — 寶石鍛造所活動頁企劃 · 供 FutureMode 黑客松與 Game Night 規劃使用 +
+
+ + diff --git a/src/components/gemforge/GemCard.tsx b/src/components/gemforge/GemCard.tsx new file mode 100644 index 0000000..4a26f4f --- /dev/null +++ b/src/components/gemforge/GemCard.tsx @@ -0,0 +1,101 @@ +import { ELEMENT_META, RARITY_META, type ForgedGem, type GemStats } from "@/lib/gemForge"; +import { GemVisual } from "./GemVisual"; + +const STAT_LABELS: { key: keyof GemStats; label: string }[] = [ + { key: "power", label: "力量" }, + { key: "guard", label: "守護" }, + { key: "spirit", label: "靈性" }, +]; + +const STAT_MAX = 12; // 能量條滿格基準(legendary 單項理論上限附近) + +/** + * 寶石卡卡面 — 3:4 直式、截圖導向設計(Game Night 分享用)。 + * 只吃 ForgedGem,不碰任何 context / 儲存,方便在成品頁與收藏頁重用。 + */ +export function GemCard({ gem }: { gem: ForgedGem }) { + const element = ELEMENT_META[gem.element]; + const rarity = RARITY_META[gem.rarity]; + + return ( +
+ {/* 頂部:稀有度 + 元素 */} +
+ + {rarity.label} + + + {element.label} + +
+ + {/* 寶石本體 */} +
+ +
+ + {/* 名稱 */} +
+

+ {gem.name} +

+

{element.blurb}

+
+ + {/* 數值 */} +
+ {STAT_LABELS.map(({ key, label }) => ( +
+ + {label} + +
+
+
+ + {gem.stats[key]} + +
+ ))} +
+ + {/* 故事 */} +

+ {gem.story} +

+ + {/* 卡腳 */} +
+ + Aurayale · 寶石鍛造所 + + + No.{gem.id.slice(-4).toUpperCase()} + +
+
+ ); +} diff --git a/src/components/gemforge/GemVisual.tsx b/src/components/gemforge/GemVisual.tsx new file mode 100644 index 0000000..99e1012 --- /dev/null +++ b/src/components/gemforge/GemVisual.tsx @@ -0,0 +1,62 @@ +import { ELEMENT_META, RARITY_META, type ForgedGem } from "@/lib/gemForge"; + +/** + * 寶石占位視覺(企劃 04:視覺產出方式暫定留空)。 + * + * 這個元件是「視覺產出」的抽換點:目前以屬性驅動的 SVG 切面寶石呈現 + * (元素決定色相、稀有度決定光暈與碎鑽數量),之後不論改接 AI 生圖 + * 或規則式美術,只要替換這個元件的內部實作即可,props 介面不變。 + */ +export function GemVisual({ gem, size = 180 }: { gem: ForgedGem; size?: number }) { + const { color } = ELEMENT_META[gem.element]; + const rarityColor = RARITY_META[gem.rarity].color; + const sparkles = { common: 0, rare: 2, epic: 4, legendary: 6 }[gem.rarity]; + + // 依稀有度加強光暈 + const glow = { + common: `drop-shadow(0 0 10px ${color}66)`, + rare: `drop-shadow(0 0 16px ${color}88)`, + epic: `drop-shadow(0 0 22px ${color}aa)`, + legendary: `drop-shadow(0 0 30px ${color}cc)`, + }[gem.rarity]; + + const sparklePositions = [ + { x: 38, y: 42, r: 2.4 }, + { x: 164, y: 58, r: 1.8 }, + { x: 150, y: 148, r: 2.2 }, + { x: 46, y: 140, r: 1.6 }, + { x: 100, y: 24, r: 2.6 }, + { x: 172, y: 108, r: 1.5 }, + ]; + + return ( + + {/* 主體輪廓 */} + + {/* 冠部切面 */} + + + + {/* 亭部切面 */} + + + + {/* 腰線 */} + + {/* 高光 */} + + {/* 稀有度碎鑽 */} + {sparklePositions.slice(0, sparkles).map((s, i) => ( + + ))} + + ); +} diff --git a/src/lib/gemForge.ts b/src/lib/gemForge.ts new file mode 100644 index 0000000..c8c4fc3 --- /dev/null +++ b/src/lib/gemForge.ts @@ -0,0 +1,287 @@ +/** + * 寶石鍛造所(/events/gem-forge)核心資料層。 + * + * 設計重點(見 docs/gem-forge-plan.html): + * - ForgeEngine 是可抽換介面:第一版為規則式引導對話(RuleBasedForgeEngine), + * 之後接 LLM API 時只要換一個 engine 實作,頁面不用動。 + * - 寶石 schema 固定,styleHints 為「視覺產出方式」保留欄位(目前留空方案)。 + * - 純前端:收藏存 localStorage,不打任何後端 API。 + */ + +export type GemElement = "fire" | "water" | "earth" | "wind" | "light" | "shadow"; +export type GemRarity = "common" | "rare" | "epic" | "legendary"; + +export interface GemStats { + /** 力量 */ + power: number; + /** 守護 */ + guard: number; + /** 靈性 */ + spirit: number; +} + +export interface ForgedGem { + id: string; + name: string; + element: GemElement; + rarity: GemRarity; + stats: GemStats; + /** 由對話素材織成的短故事 */ + story: string; + /** 視覺產出保留欄位:之後接 AI 生圖 / 規則式美術時的風格提示 */ + styleHints: string[]; + /** 原始對話回答,供之後接 LLM / 生圖時當素材 */ + answers: string[]; + createdAt: number; +} + +export interface ChatMessage { + role: "forge" | "user"; + text: string; +} + +export type ForgeStep = + | { type: "question"; message: string; hints?: string[] } + | { type: "complete"; gem: ForgedGem; message: string }; + +/** 對話引擎介面:輸入完整對話歷史,回傳下一句提問或最終寶石。 */ +export interface ForgeEngine { + next(history: ChatMessage[]): Promise; +} + +/* ------------------------------------------------------------------ */ +/* 元素 / 稀有度 中繼資料(GemVisual 與卡面共用) */ +/* ------------------------------------------------------------------ */ + +export const ELEMENT_META: Record< + GemElement, + { label: string; color: string; colorSoft: string; blurb: string } +> = { + fire: { label: "焰", color: "#e2612e", colorSoft: "#5a2415", blurb: "在熔岩的怒吼中成形" }, + water: { label: "潮", color: "#3f8fc4", colorSoft: "#16344a", blurb: "凝結自深海最靜的一滴" }, + earth: { label: "壤", color: "#7fa14c", colorSoft: "#2c3a1a", blurb: "億年岩層裡緩慢醒來" }, + wind: { label: "嵐", color: "#8fc4bc", colorSoft: "#27423e", blurb: "被高空的風磨出稜角" }, + light: { label: "曜", color: "#e0b64f", colorSoft: "#4c3c14", blurb: "折射黎明的第一道光" }, + shadow: { label: "冥", color: "#9d6fe0", colorSoft: "#33244c", blurb: "自無星的夜裡剝落" }, +}; + +export const RARITY_META: Record< + GemRarity, + { label: string; color: string; statBudget: number } +> = { + common: { label: "凡晶", color: "#9aa8a1", statBudget: 9 }, + rare: { label: "秘石", color: "#4f9dd8", statBudget: 12 }, + epic: { label: "史詩", color: "#a06ce0", statBudget: 15 }, + legendary: { label: "傳說", color: "#d9a44a", statBudget: 18 }, +}; + +/* ------------------------------------------------------------------ */ +/* 規則式對話引擎(v1) */ +/* ------------------------------------------------------------------ */ + +const QUESTIONS: { message: string; hints?: string[] }[] = [ + { + message: + "歡迎來到鍛造所,旅人。爐火已經升起——先告訴我,你的寶石誕生於什麼樣的地方?", + hints: ["火山的心臟", "深海的裂縫", "古老的森林", "高空的風暴", "黎明的山頂", "無星的暗夜"], + }, + { + message: "很好,我看見它的胚胎了。那麼,將佩戴它的人是什麼樣的性子?", + hints: ["勇往直前的戰士", "溫柔守護家人的人", "追逐星空的夢想家"], + }, + { + message: "爐火正旺。最後的素材——你希望這顆寶石替你守護什麼?", + }, + { + message: "成形之前,為它取個名字吧。(留空的話,就交給鍛造爐替你命名)", + }, +]; + +// 注意:避免過於通用的單字(如「地」「山」會誤傷「地方」「火山」), +// 多字詞在計分時以字數加權,讓「熔岩」「岩漿」比單一字更有代表性。 +const ELEMENT_KEYWORDS: Record = { + fire: ["火", "熔岩", "岩漿", "炎", "燃", "燒", "烈日", "太陽", "灼"], + water: ["水", "海", "河", "冰", "雨", "潮", "湖", "淚", "霧"], + earth: ["森", "林", "樹", "土", "大地", "岩層", "山谷", "山脈", "苔", "礦"], + wind: ["風", "天空", "雲", "嵐", "高空", "羽", "飛"], + light: ["光", "黎明", "晨", "星", "日出", "聖", "曙", "白晝"], + shadow: ["夜", "暗", "影", "月", "黑", "冥", "幽", "深淵", "夢魘"], +}; + +const STAT_KEYWORDS: Record = { + power: ["勇", "戰", "強", "衝", "熱血", "冒險", "直前", "拳"], + guard: ["守", "護", "家", "溫柔", "安", "穩", "陪伴", "照顧"], + spirit: ["夢", "星", "智", "書", "想像", "藝術", "好奇", "追逐"], +}; + +/** 依元素給的命名素材(使用者留空名字時用) */ +const NAME_POOLS: Record = { + fire: { prefixes: ["燼", "焰", "赤", "曦"], suffixes: ["之心", "餘火", "晨曜", "殘響"] }, + water: { prefixes: ["汐", "淵", "滄", "露"], suffixes: ["之淚", "迴聲", "靜謐", "深眠"] }, + earth: { prefixes: ["苔", "磐", "翠", "壤"], suffixes: ["之脈", "年輪", "沉思", "根鳴"] }, + wind: { prefixes: ["嵐", "翎", "霄", "颯"], suffixes: ["之息", "遠行", "輕語", "掠影"] }, + light: { prefixes: ["曜", "晨", "煌", "澄"], suffixes: ["之誓", "初光", "凝輝", "破曉"] }, + shadow: { prefixes: ["冥", "宵", "魅", "玄"], suffixes: ["之瞳", "殘月", "低語", "夜幕"] }, +}; + +/** 簡單字串雜湊,讓同樣的回答鍛出同一顆寶石(demo 時好重現) */ +function hashText(text: string): number { + let h = 0; + for (let i = 0; i < text.length; i++) { + h = (h * 31 + text.charCodeAt(i)) >>> 0; + } + return h; +} + +function detectElement(text: string): GemElement { + let best: GemElement = "light"; + let bestScore = 0; + (Object.keys(ELEMENT_KEYWORDS) as GemElement[]).forEach((el) => { + const score = ELEMENT_KEYWORDS[el].reduce( + (acc, kw) => acc + (text.includes(kw) ? kw.length : 0), + 0 + ); + if (score > bestScore) { + best = el; + bestScore = score; + } + }); + if (bestScore > 0) return best; + // 沒對到關鍵字時,用雜湊穩定分配一個元素 + const all: GemElement[] = ["fire", "water", "earth", "wind", "light", "shadow"]; + return all[hashText(text) % all.length]; +} + +/** + * 稀有度由「素材豐富度」決定:描述越用心,稀有度越高。 + * 這是刻意的活動機制 —— 鼓勵現場玩家多聊幾句。 + */ +function deriveRarity(answers: string[]): GemRarity { + const totalLen = answers.join("").length; + const richness = totalLen + (hashText(answers.join("|")) % 10); + if (richness >= 80) return "legendary"; + if (richness >= 45) return "epic"; + if (richness >= 20) return "rare"; + return "common"; +} + +function deriveStats(personality: string, rarity: GemRarity, seed: number): GemStats { + const budget = RARITY_META[rarity].statBudget; + const bias: GemStats = { power: 1, guard: 1, spirit: 1 }; + (Object.keys(STAT_KEYWORDS) as (keyof GemStats)[]).forEach((stat) => { + STAT_KEYWORDS[stat].forEach((kw) => { + if (personality.includes(kw)) bias[stat] += 2; + }); + }); + const biasTotal = bias.power + bias.guard + bias.spirit; + const raw: GemStats = { + power: Math.max(1, Math.round((bias.power / biasTotal) * budget)), + guard: Math.max(1, Math.round((bias.guard / biasTotal) * budget)), + spirit: Math.max(1, Math.round((bias.spirit / biasTotal) * budget)), + }; + // 用 seed 做 ±1 的微調,讓同 rarity 不同回答仍有差異 + const wiggle = seed % 3; + if (wiggle === 0) raw.power += 1; + else if (wiggle === 1) raw.guard += 1; + else raw.spirit += 1; + return raw; +} + +function buildName(userName: string, element: GemElement, seed: number): string { + const trimmed = userName.trim(); + if (trimmed && trimmed !== "") return trimmed.slice(0, 12); + const pool = NAME_POOLS[element]; + const prefix = pool.prefixes[seed % pool.prefixes.length]; + const suffix = pool.suffixes[(seed >>> 3) % pool.suffixes.length]; + return `${prefix}${suffix}`; +} + +function buildStory(answers: string[], element: GemElement): string { + const [origin, personality, wish] = answers; + const meta = ELEMENT_META[element]; + const originLine = origin.trim() ? `它${meta.blurb},記得${origin.trim()}的模樣。` : `它${meta.blurb}。`; + const ownerLine = personality.trim() + ? `如今它選擇了${personality.trim()}作為同行者,` + : "如今它靜靜等待著同行者,"; + const wishLine = wish.trim() + ? `並立下誓言——守護「${wish.trim()}」,直到爐火熄滅的那一天。` + : "等待著一個值得守護的誓言。"; + return `${originLine}${ownerLine}${wishLine}`; +} + +export class RuleBasedForgeEngine implements ForgeEngine { + async next(history: ChatMessage[]): Promise { + const userAnswers = history.filter((m) => m.role === "user").map((m) => m.text); + + if (userAnswers.length < QUESTIONS.length) { + const q = QUESTIONS[userAnswers.length]; + return { type: "question", message: q.message, hints: q.hints }; + } + + const [origin, personality, wish, rawName] = userAnswers; + const seed = hashText(userAnswers.join("|")); + const element = detectElement(`${origin} ${wish}`); + const rarity = deriveRarity([origin, personality, wish]); + const gem: ForgedGem = { + id: `gem-${Date.now().toString(36)}-${(seed % 0xffff).toString(16)}`, + name: buildName(rawName, element, seed), + element, + rarity, + stats: deriveStats(personality, rarity, seed), + story: buildStory([origin, personality, wish], element), + styleHints: [ + `element:${element}`, + `rarity:${rarity}`, + ...[origin, personality, wish].filter((a) => a.trim()).map((a) => `material:${a.trim().slice(0, 24)}`), + ], + answers: userAnswers, + createdAt: Date.now(), + }; + + return { + type: "complete", + gem, + message: `鏗——成了。${RARITY_META[rarity].label}級的「${gem.name}」,請收下吧。`, + }; + } +} + +/* ------------------------------------------------------------------ */ +/* localStorage 收藏(純前端,不存後端) */ +/* ------------------------------------------------------------------ */ + +const COLLECTION_KEY = "gemForge.collection.v1"; + +export function loadCollection(): ForgedGem[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(COLLECTION_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as ForgedGem[]) : []; + } catch { + return []; + } +} + +export function saveToCollection(gem: ForgedGem): ForgedGem[] { + const current = loadCollection(); + if (current.some((g) => g.id === gem.id)) return current; + const next = [gem, ...current]; + try { + window.localStorage.setItem(COLLECTION_KEY, JSON.stringify(next)); + } catch { + // localStorage 滿 / 無痕模式:收藏失敗不阻斷體驗 + } + return next; +} + +export function removeFromCollection(id: string): ForgedGem[] { + const next = loadCollection().filter((g) => g.id !== id); + try { + window.localStorage.setItem(COLLECTION_KEY, JSON.stringify(next)); + } catch { + // 同上,靜默失敗 + } + return next; +} diff --git a/src/pages/events/gem-forge.tsx b/src/pages/events/gem-forge.tsx new file mode 100644 index 0000000..5b7aa68 --- /dev/null +++ b/src/pages/events/gem-forge.tsx @@ -0,0 +1,404 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Head from "next/head"; +import { Noto_Serif_TC } from "next/font/google"; +import { + RuleBasedForgeEngine, + loadCollection, + removeFromCollection, + saveToCollection, + RARITY_META, + ELEMENT_META, + type ChatMessage, + type ForgedGem, +} from "@/lib/gemForge"; +import { GemCard } from "@/components/gemforge/GemCard"; +import { GemVisual } from "@/components/gemforge/GemVisual"; + +const gemSerif = Noto_Serif_TC({ + weight: ["600", "700", "900"], + subsets: ["latin"], + preload: false, + variable: "--font-gem-serif", +}); + +type Phase = "intro" | "chat" | "forging" | "result" | "collection"; + +/** + * 寶石鍛造所(企劃:docs/gem-forge-plan.html) + * + * 黑客松 demo + Game Night 線下活動共用的獨立活動頁: + * - 匿名體驗:不掛 Privy / UserContext / AuraServer,零登入摩擦。 + * - 對話引擎走 ForgeEngine 介面,目前是規則式(RuleBasedForgeEngine), + * 之後接 LLM 只要換 engine。 + * - 收藏純 localStorage,不打後端。 + */ +export default function GemForgePage() { + const engine = useMemo(() => new RuleBasedForgeEngine(), []); + + const [phase, setPhase] = useState("intro"); + const [messages, setMessages] = useState([]); + const [hints, setHints] = useState([]); + const [input, setInput] = useState(""); + const [isTyping, setIsTyping] = useState(false); + const [gem, setGem] = useState(null); + const [forgeMessage, setForgeMessage] = useState(""); + const [collection, setCollection] = useState([]); + const [viewingGem, setViewingGem] = useState(null); + const [toast, setToast] = useState(""); + + const messagesEndRef = useRef(null); + const timersRef = useRef[]>([]); + + useEffect(() => { + setCollection(loadCollection()); + const timers = timersRef.current; + return () => timers.forEach(clearTimeout); + }, []); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); + }, [messages, isTyping]); + + const later = (fn: () => void, ms: number) => { + timersRef.current.push(setTimeout(fn, ms)); + }; + + const showToast = (text: string) => { + setToast(text); + later(() => setToast(""), 2200); + }; + + const isSaved = gem !== null && collection.some((g) => g.id === gem.id); + + /** 把引擎的下一步(提問或成品)演出來:打字延遲 → 訊息進場 */ + const advance = async (history: ChatMessage[]) => { + setIsTyping(true); + setHints([]); + const step = await engine.next(history); + later(() => { + setIsTyping(false); + if (step.type === "question") { + setMessages((prev) => [...prev, { role: "forge", text: step.message }]); + setHints(step.hints ?? []); + } else { + setGem(step.gem); + setForgeMessage(step.message); + setPhase("forging"); + later(() => setPhase("result"), 2600); + } + }, 700); + }; + + const startForge = () => { + setMessages([]); + setGem(null); + setForgeMessage(""); + setPhase("chat"); + void advance([]); + }; + + const send = (text: string) => { + const trimmed = text.trim(); + // 命名回合允許空白送出(交給鍛造爐命名),其他回合忽略空輸入 + const userCount = messages.filter((m) => m.role === "user").length; + if (!trimmed && userCount < 3) return; + if (isTyping) return; + const next: ChatMessage[] = [...messages, { role: "user", text: trimmed || "(交給爐火決定)" }]; + setMessages(next); + setInput(""); + void advance(next); + }; + + const saveGem = () => { + if (!gem) return; + setCollection(saveToCollection(gem)); + showToast("已收進你的寶石匣"); + }; + + const shareGem = async (target: ForgedGem) => { + const text = `我在 Aurayale 寶石鍛造所鍛出了${RARITY_META[target.rarity].label}級寶石「${target.name}」——${target.story}`; + const url = typeof window !== "undefined" ? window.location.href : ""; + try { + if (navigator.share) { + await navigator.share({ text, url }); + } else { + await navigator.clipboard.writeText(`${text} ${url}`); + showToast("分享文字已複製"); + } + } catch { + // 使用者取消分享:不做事 + } + }; + + const deleteGem = (id: string) => { + setCollection(removeFromCollection(id)); + setViewingGem(null); + showToast("已從寶石匣移除"); + }; + + return ( +
+ + 寶石鍛造所 | Aurayale + + + + {/* 頂欄 */} +
+ + +
+ +
+ {phase === "intro" && ( +
+ +
+

+ Aurayale Event +

+

+ 把你的想像 +
+ 鍛成一顆寶石 +

+

+ 在 Aurayale + 的世界深處有一座只在活動之夜開爐的鍛造所。與鍛造師聊上幾句——說出它誕生的地方、佩戴者的性子、想守護的事物——爐火會替你把這些話語鍛成一顆獨一無二的寶石。 +

+
+
+ +

+ 免登入・約一分鐘・寶石只存在你的裝置上 +

+
+
+ )} + + {phase === "chat" && ( +
+
+ {messages.map((m, i) => ( +
+ {m.text} +
+ ))} + {isTyping && ( +
+ + + +
+ )} +
+
+ + {hints.length > 0 && !isTyping && ( +
+ {hints.map((h) => ( + + ))} +
+ )} + +
{ + e.preventDefault(); + send(input); + }} + className="gem-panel flex items-center gap-2 rounded-full pl-4 pr-1.5 py-1.5 mt-2" + > + setInput(e.target.value)} + placeholder="對鍛造師說……" + className="flex-1 bg-transparent text-sm text-[#e8e5da] placeholder-[#5d675f] outline-none" + maxLength={80} + /> + +
+
+ )} + + {phase === "forging" && gem && ( +
+
+ + +
+ +
+
+

+ 鍛造中⋯⋯ +

+
+ )} + + {phase === "result" && gem && ( +
+

+ {forgeMessage} +

+
+ +
+
+
+ + +
+ +
+
+ )} + + {phase === "collection" && !viewingGem && ( +
+

我的寶石匣

+

+ 收藏存在這台裝置的瀏覽器裡,共 {collection.length} 顆。 +

+ {collection.length === 0 ? ( +
+

爐火還沒替你鍛出任何寶石。

+ +
+ ) : ( +
+ {collection.map((g) => ( + + ))} +
+ )} +
+ )} + + {phase === "collection" && viewingGem && ( +
+
+ +
+
+ + + +
+
+ )} +
+ + {/* Toast */} + {toast && ( +
+ {toast} +
+ )} +
+ ); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 93212a4..c4c9abc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -982,3 +982,172 @@ body { .landing-page ::-webkit-scrollbar-track { background: #0a0a0c; } .landing-page ::-webkit-scrollbar-thumb { background: #222; border-radius: 4px; } .landing-page ::-webkit-scrollbar-thumb:hover { background: #6366f1; } + +/* ========== Gem Forge Event Styles (/events/gem-forge) ========== */ + +.gem-forge-page { + min-height: 100vh; + min-height: 100dvh; + background-color: #0b0f0e; + color: #d8ded8; + background-image: + radial-gradient(ellipse 70% 40% at 50% -5%, rgba(200, 167, 90, 0.09) 0%, transparent 60%), + radial-gradient(ellipse 50% 35% at 85% 100%, rgba(63, 143, 196, 0.05) 0%, transparent 60%), + radial-gradient(ellipse 45% 30% at 10% 90%, rgba(226, 97, 46, 0.05) 0%, transparent 60%); +} + +.gem-serif { + font-family: var(--font-gem-serif, "Noto Serif TC"), "Noto Serif TC", "Songti TC", serif; +} + +/* 鍛造金骨架線 */ +.gem-panel { + background: rgba(19, 26, 23, 0.85); + border: 1px solid rgba(200, 167, 90, 0.22); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 12px 40px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.gem-card { + background: + radial-gradient(ellipse 90% 55% at 50% 30%, var(--gem-color-soft, #1c2621) 0%, transparent 75%), + linear-gradient(180deg, #131a17 0%, #0b0f0e 100%); + border: 1px solid rgba(200, 167, 90, 0.45); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + inset 0 0 60px rgba(0, 0, 0, 0.35), + 0 16px 48px rgba(0, 0, 0, 0.6); +} +.gem-card::before { + content: ""; + position: absolute; + inset: 6px; + border: 1px solid rgba(200, 167, 90, 0.18); + border-radius: 12px; + pointer-events: none; +} + +.gem-element-chip { + border: 1px solid; + border-radius: 9999px; + padding: 0.15rem 0.65rem; +} + +.gem-card-footer { + border-top: 1px solid rgba(200, 167, 90, 0.18); + background: rgba(0, 0, 0, 0.25); +} + +/* 對話氣泡 */ +.gem-msg { + animation: gemMsgIn 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} +.gem-msg-forge { + background: rgba(200, 167, 90, 0.08); + border: 1px solid rgba(200, 167, 90, 0.22); + border-radius: 2px 14px 14px 14px; +} +.gem-msg-user { + background: rgba(63, 143, 196, 0.1); + border: 1px solid rgba(63, 143, 196, 0.25); + border-radius: 14px 2px 14px 14px; +} +@keyframes gemMsgIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* 快速提示 chips */ +.gem-hint-chip { + border: 1px solid rgba(200, 167, 90, 0.3); + background: rgba(200, 167, 90, 0.06); + color: #c8a75a; + border-radius: 9999px; + transition: background 150ms, border-color 150ms; +} +.gem-hint-chip:hover { + background: rgba(200, 167, 90, 0.16); + border-color: rgba(200, 167, 90, 0.55); +} + +/* 打字中指示 */ +.gem-typing-dot { + width: 5px; + height: 5px; + border-radius: 9999px; + background: #c8a75a; + animation: gemTyping 1.2s ease-in-out infinite; +} +.gem-typing-dot:nth-child(2) { animation-delay: 0.15s; } +.gem-typing-dot:nth-child(3) { animation-delay: 0.3s; } +@keyframes gemTyping { + 0%, 60%, 100% { opacity: 0.25; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-3px); } +} + +/* 寶石漂浮 */ +.gem-visual-float { + animation: gemFloat 4.5s ease-in-out infinite; +} +@keyframes gemFloat { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +/* 鍛造中:脈動 + 光環 */ +.gem-forging-pulse { + animation: gemForgePulse 1.1s ease-in-out infinite; +} +@keyframes gemForgePulse { + 0%, 100% { transform: scale(1); filter: brightness(0.9); } + 50% { transform: scale(1.08); filter: brightness(1.35); } +} +.gem-forging-ring { + border: 1px solid rgba(200, 167, 90, 0.5); + border-radius: 9999px; + animation: gemForgeRing 1.6s cubic-bezier(0.16, 1, 0.3, 1) infinite; +} +@keyframes gemForgeRing { + from { transform: scale(0.6); opacity: 0.9; } + to { transform: scale(1.5); opacity: 0; } +} + +/* 成品入場 */ +.gem-result-enter { + animation: gemResultIn 0.7s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes gemResultIn { + from { opacity: 0; transform: scale(0.88) translateY(24px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.gem-btn-primary { + background: linear-gradient(180deg, #d8b96b 0%, #a8823e 100%); + color: #1a130a; + box-shadow: 0 2px 12px rgba(200, 167, 90, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.5); + transition: filter 150ms, transform 150ms; +} +.gem-btn-primary:hover { filter: brightness(1.08); } +.gem-btn-primary:active { transform: scale(0.98); } +.gem-btn-ghost { + border: 1px solid rgba(200, 167, 90, 0.35); + color: #c8a75a; + transition: background 150ms; +} +.gem-btn-ghost:hover { background: rgba(200, 167, 90, 0.08); } + +@media (prefers-reduced-motion: reduce) { + .gem-visual-float, + .gem-forging-pulse, + .gem-forging-ring, + .gem-typing-dot { + animation: none !important; + } + .gem-msg, + .gem-result-enter { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } +}