Compare commits
4 Commits
25a38cbf05
...
codex/mvp-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed54f6a0f2 | ||
|
|
92bf1f5070 | ||
|
|
5c2b4f40f9 | ||
|
|
c118e24bd1 |
83
assets/prompts/bangkok-3d-prompts.md
Normal file
83
assets/prompts/bangkok-3d-prompts.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 曼谷 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
|
||||
---
|
||||
|
||||
## bangkok_01 — 玉佛寺
|
||||
|
||||
```
|
||||
3D rendered icon of Wat Phra Kaew Temple of the Emerald Buddha, miniature Thai temple with golden spired roof and ornate multi-tiered structure, white and gold facade with intricate carvings, realistic gilded material with metallic reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_02 — 佛塔
|
||||
|
||||
```
|
||||
3D rendered icon of Thai Buddhist stupa chedi, tall golden bell-shaped pagoda tower with pointed spire and tiered base, realistic polished gold material with warm reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_03 — 冬阴功
|
||||
|
||||
```
|
||||
3D rendered icon of Thai tom yum kung soup, white ceramic bowl filled with spicy red-orange broth, pink shrimp, sliced mushrooms, lemongrass stalks and kaffir lime leaves, realistic liquid surface with steam rising, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_04 — 芒果糯米饭
|
||||
|
||||
```
|
||||
3D rendered icon of Thai mango sticky rice dessert, sliced golden ripe mango beside a mound of white glutinous sticky rice drizzled with coconut cream on a white plate, glossy surface with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_05 — 船面
|
||||
|
||||
```
|
||||
3D rendered icon of Thai boat noodles, small dark brown ceramic bowl with rich dark broth, thin rice noodles, sliced beef, bean sprouts and fresh herbs, realistic liquid surface and material textures, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_06 — 泰丝
|
||||
|
||||
```
|
||||
3D rendered icon of Thai silk fabric roll, luxurious shimmering purple and gold woven silk fabric elegantly draped and folded, intricate traditional Thai pattern with geometric motifs, realistic iridescent silk texture with light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_07 — 泰拳手套
|
||||
|
||||
```
|
||||
3D rendered icon of Muay Thai boxing gloves, pair of red leather boxing gloves with golden Thai script pattern, realistic stitched leather material with worn texture, crossed position showing both gloves, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_08 — 嘟嘟车
|
||||
|
||||
```
|
||||
3D rendered icon of Thai tuk-tuk three-wheeled taxi, colorful blue and yellow motorized rickshaw with open passenger seat and fabric canopy, realistic painted metal and chrome material, cute compact vehicle, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_09 — 椰子杯
|
||||
|
||||
```
|
||||
3D rendered icon of Thai coconut drink, half-cut brown coconut shell used as a cup filled with white coconut water, small paper umbrella and green straw, realistic hairy coconut husk texture and translucent liquid, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_10 — 莲花
|
||||
|
||||
```
|
||||
3D rendered icon of pink lotus flower, beautiful blooming lotus with layered soft pink petals opening from center, large round green lily pad underneath, realistic translucent petal texture with water droplets, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_11 — 大象
|
||||
|
||||
```
|
||||
3D rendered icon of Thai elephant, cute miniature gray Asian elephant with large ears and short tusks, wearing a colorful traditional Thai decorative blanket and golden headpiece, realistic wrinkled skin texture, friendly expression, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## bangkok_12 — 榴莲
|
||||
|
||||
```
|
||||
3D rendered icon of durian fruit, whole spiky yellow-green durian with sharp thorny shell partially cut open revealing creamy golden yellow flesh inside, realistic spiky texture with natural color gradient, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_bangkok — 泰泰猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing Siamese cat with creamy beige fur and dark brown face mask ears and nose, wearing a colorful Thai flower garland lei around the neck with jasmine and marigold flowers, big round blue eyes with cute sweet expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
199
assets/prompts/batch-1-beijing-tokyo-bangkok.md
Normal file
199
assets/prompts/batch-1-beijing-tokyo-bangkok.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Batch 1 — AI Image Prompts
|
||||
|
||||
> 北京 (Beijing) / 东京 (Tokyo) / 曼谷 (Bangkok)
|
||||
>
|
||||
> 生成工具:Midjourney / DALL-E
|
||||
> 用途:城市抓猫猫 — 元素图标 + 猫猫头像 + 护照邮票
|
||||
|
||||
---
|
||||
|
||||
## 统一风格后缀
|
||||
|
||||
**元素图标:**
|
||||
```
|
||||
, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw
|
||||
```
|
||||
|
||||
**猫猫头像:**
|
||||
```
|
||||
, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw
|
||||
```
|
||||
|
||||
**护照邮票:**
|
||||
```
|
||||
, white background --ar 3:4 --style raw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 北京 Beijing
|
||||
|
||||
### 元素(12 张)
|
||||
|
||||
### beijing_01 — 天安门
|
||||
`flat design icon of Tiananmen Gate, the iconic red Chinese gate with golden roof tiles and five arched doorways, frontal view, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_02 — 鸟巢
|
||||
`flat design icon of Beijing National Stadium (Bird's Nest), the interwoven steel lattice structure in gray and red, simplified architectural shape, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_03 — 糖葫芦
|
||||
`flat design icon of Chinese tanghulu, red candied hawthorn berries on a wooden stick, glossy candy coating, five round berries in a row, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_04 — 烤鸭
|
||||
`flat design icon of Peking roast duck, whole roasted duck with crispy golden-brown lacquered skin on a white plate, glistening surface, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_05 — 豆汁
|
||||
`flat design icon of Beijing douzhir (fermented mung bean drink), a traditional Chinese ceramic bowl filled with pale greenish-gray liquid, small side dish of pickled vegetables, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_06 — 京剧脸谱
|
||||
`flat design icon of Chinese Peking Opera face mask, bold geometric red white and black painted pattern, dramatic facial design with fierce expression, traditional jing (painted face) role, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_07 — 兔儿爷
|
||||
`flat design icon of Tu'er Ye (Lord Rabbit), traditional Beijing clay figurine of a rabbit deity wearing golden armor and a red cape, sitting upright with long upright ears, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_08 — 毛笔
|
||||
`flat design icon of Chinese calligraphy brush (maobi), traditional writing brush with bamboo handle and soft black bristle tip, ink drip at the tip, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_09 — 铜锣
|
||||
`flat design icon of Chinese brass gong (tongluo), circular golden bronze gong with a wooden mallet, traditional percussion instrument, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_10 — 银杏叶
|
||||
`flat design icon of golden ginkgo leaf, single fan-shaped ginkgo biloba leaf in bright yellow-gold color with delicate vein pattern, autumn theme, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_11 — 玉兰花
|
||||
`flat design icon of white magnolia flower (yulan), single blooming magnolia with large creamy white petals and a hint of pink at the base, Beijing city flower, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### beijing_12 — 白鸽
|
||||
`flat design icon of white peace dove, a plump white pigeon in flight with wings spread, reminiscent of Tiananmen Square dove release, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### 猫猫
|
||||
|
||||
### cat_beijing — 京京
|
||||
`flat design cat head icon, front-facing orange tabby cat with darker orange tiger stripes, wearing a traditional Chinese tiger head hat (虎头帽) in red and gold with embroidered tiger face pattern, cute smiling expression with round eyes, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### 邮票
|
||||
|
||||
### stamp_beijing — 北京邮票
|
||||
`flat design vintage postage stamp, perforated edges, Tiananmen Gate illustration in center with golden roof and red walls, "BEIJING" text at bottom, red and gold color scheme, retro Chinese travel poster style, simple clean illustration, white background --ar 3:4 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 2. 东京 Tokyo
|
||||
|
||||
### 元素(12 张)
|
||||
|
||||
### tokyo_01 — 东京塔
|
||||
`flat design icon of Tokyo Tower, the iconic orange-red steel lattice tower with white stripes, tall and slender silhouette against nothing, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_02 — 鸟居
|
||||
`flat design icon of red Shinto torii gate, traditional vermillion red wooden gate with two vertical pillars and curved top beam, iconic Japanese shrine entrance, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_03 — 寿司
|
||||
`flat design icon of nigiri sushi, two pieces of nigiri with fresh salmon (orange) and tuna (red) slices on oval rice beds, glossy fish surface, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_04 — 拉面
|
||||
`flat design icon of Japanese ramen, steaming bowl of tonkotsu ramen with rich broth, wavy noodles, soft-boiled egg half, chashu pork slice, nori seaweed, and chopsticks resting on the bowl, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_05 — 章鱼烧
|
||||
`flat design icon of takoyaki, four round golden-brown octopus balls in a paper boat tray, topped with mayo drizzle, takoyaki sauce, bonito flakes, and green onion, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_06 — 招财猫
|
||||
`flat design icon of maneki-neko lucky cat figurine, white ceramic cat with one paw raised and waving, wearing a red collar with golden bell, sitting upright, traditional Japanese good luck charm, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_07 — 浮世绘
|
||||
`flat design icon of ukiyo-e style great wave, simplified version of the famous Hokusai wave with blue curling ocean wave and white foam, miniature Mount Fuji in background, Japanese woodblock print aesthetic, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_08 — 和服扇
|
||||
`flat design icon of Japanese folding fan (sensu), opened fan with traditional red and gold kimono pattern featuring cherry blossom motifs, elegant curved shape, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_09 — 新干线票
|
||||
`flat design icon of Shinkansen bullet train ticket, rectangular train ticket with a small side illustration of a white and blue bullet train, simplified Japanese railway ticket design with punched hole, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_10 — 樱花
|
||||
`flat design icon of cherry blossom branch, a short branch with three to five pink sakura flowers in full bloom and small buds, soft pink petals with yellow centers, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_11 — 富士山
|
||||
`flat design icon of Mount Fuji, the iconic symmetrical volcanic cone with snow-capped white peak and blue-purple slopes, small clouds near the summit, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### tokyo_12 — 锦鲤
|
||||
`flat design icon of Japanese koi fish, a single ornamental koi with orange and white patches swimming gracefully, flowing fins and tail, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### 猫猫
|
||||
|
||||
### cat_tokyo — 樱丸
|
||||
`flat design cat head icon, front-facing white cat with soft fluffy fur, wearing a golden bell on a red collar around the neck like a maneki-neko lucky cat, pink inner ears, cute smiling expression with round sparkling eyes, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### 邮票
|
||||
|
||||
### stamp_tokyo — 东京邮票
|
||||
`flat design vintage postage stamp, perforated edges, Tokyo Tower illustration in center with cherry blossom branches framing the sides, "TOKYO" text at bottom, pink and orange-red color scheme, retro Japanese travel poster style, simple clean illustration, white background --ar 3:4 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 3. 曼谷 Bangkok
|
||||
|
||||
### 元素(12 张)
|
||||
|
||||
### bangkok_01 — 玉佛寺
|
||||
`flat design icon of Wat Phra Kaew (Temple of the Emerald Buddha), golden Thai temple with ornate multi-tiered roof, pointed spires and green glass mosaic details, traditional Thai Buddhist architecture, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_02 — 佛塔
|
||||
`flat design icon of Thai Buddhist stupa (chedi), golden pointed pagoda with bell-shaped base and tapering spire, ornate tiered rings, gleaming gold surface, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_03 — 冬阴功
|
||||
`flat design icon of Tom Yum Goong soup, a bowl of spicy red-orange Thai soup with whole shrimp, mushrooms, lemongrass stalks, kaffir lime leaves, and chili, steaming hot, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_04 — 芒果糯米饭
|
||||
`flat design icon of Thai mango sticky rice (khao niao mamuang), sliced golden ripe mango next to a mound of white sticky rice drizzled with coconut cream, served on a plate, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_05 — 船面
|
||||
`flat design icon of Thai boat noodles, a small dark bowl of rich dark broth noodle soup with beef slices, bean sprouts, and herbs, chopsticks and a Chinese spoon, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_06 — 泰丝
|
||||
`flat design icon of Thai silk fabric roll, a rolled bolt of luxurious Thai silk in shimmering purple and gold with traditional ikat-woven geometric pattern, elegant textile, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_07 — 泰拳手套
|
||||
`flat design icon of Muay Thai boxing gloves, a pair of red boxing gloves with golden trim and traditional Thai pattern embroidery, crossed together, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_08 — 嘟嘟车
|
||||
`flat design icon of Thai tuk-tuk, a colorful three-wheeled auto rickshaw in bright blue and yellow with a red canopy roof, front-facing view, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_09 — 椰子杯
|
||||
`flat design icon of fresh green coconut drink, a young green coconut cut open with a colorful paper straw and a small umbrella, tropical refreshment, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_10 — 莲花
|
||||
`flat design icon of pink lotus flower, a single blooming sacred lotus with layered pink petals opening upward and a golden center, floating on a green lily pad, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_11 — 大象
|
||||
`flat design icon of Thai elephant, a cute baby elephant with large floppy ears wearing a colorful decorative cloth blanket with golden trim on its back, trunk slightly raised, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### bangkok_12 — 榴莲
|
||||
`flat design icon of durian fruit, a whole durian with spiky yellowish-green shell cut open to reveal creamy golden yellow flesh inside, the king of fruits, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### 猫猫
|
||||
|
||||
### cat_bangkok — 泰泰
|
||||
`flat design cat head icon, front-facing Siamese cat with cream-colored body and dark brown points on ears and face, bright blue almond-shaped eyes, wearing a traditional Thai jasmine flower garland (phuang malai) around the neck in white and purple, cute smiling expression, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### 邮票
|
||||
|
||||
### stamp_bangkok — 曼谷邮票
|
||||
`flat design vintage postage stamp, perforated edges, golden Buddha temple (Wat Phra Kaew) illustration in center, ornate Thai pattern border with traditional Lai Thai floral motifs, "BANGKOK" text at bottom, gold and orange color scheme, retro Thai travel poster style, simple clean illustration, white background --ar 3:4 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 生成清单
|
||||
|
||||
| 城市 | 元素 | 猫猫 | 邮票 | 小计 |
|
||||
|------|------|------|------|------|
|
||||
| 北京 | 12 | 1 | 1 | 14 |
|
||||
| 东京 | 12 | 1 | 1 | 14 |
|
||||
| 曼谷 | 12 | 1 | 1 | 14 |
|
||||
| **合计** | **36** | **3** | **3** | **42** |
|
||||
|
||||
## 输出规范
|
||||
|
||||
- 元素图标:512x512px PNG,透明背景裁切后放入 `images/elements/{city}/`
|
||||
- 猫猫头像:512x512px PNG,放入 `images/cats/`
|
||||
- 护照邮票:384x512px PNG,放入 `images/stamps/`
|
||||
- 文件名与代码中 `id` 字段一致(如 `beijing_01.png`, `cat_beijing.png`, `stamp_beijing.png`)
|
||||
162
assets/prompts/batch-2-seoul-singapore-istanbul.md
Normal file
162
assets/prompts/batch-2-seoul-singapore-istanbul.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Batch 2 -- AI Image Prompts
|
||||
|
||||
> 首尔 / 新加坡 / 伊斯坦布尔
|
||||
|
||||
**统一风格后缀(元素图标):**
|
||||
```
|
||||
, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw
|
||||
```
|
||||
|
||||
**统一风格后缀(猫猫):**
|
||||
```
|
||||
, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 首尔 Seoul(12 元素 + 1 猫猫 + 1 邮票 = 14 张)
|
||||
|
||||
### seoul_01 -- 景福宫
|
||||
`flat design icon of Gyeongbokgung Palace gate, traditional Korean palace with colorful dancheong roof and stone base, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_02 -- 南山塔
|
||||
`flat design icon of N Seoul Tower, tall observation tower on mountain top with red-orange color, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_03 -- 石锅拌饭
|
||||
`flat design icon of bibimbap in hot stone bowl, colorful vegetables and egg on rice in dark stone pot with sizzling effect, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_04 -- 韩式炸鸡
|
||||
`flat design icon of Korean fried chicken piece, golden crispy drumstick with glossy glaze and sesame seeds, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_05 -- 海苔卷
|
||||
`flat design icon of gimbap Korean sushi roll, cross-section showing rice and colorful fillings wrapped in dark green seaweed, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_06 -- 韩服
|
||||
`flat design icon of hanbok traditional Korean dress, beautiful jeogori top and chima skirt in pink and blue colors with ribbon bow, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_07 -- K-pop话筒
|
||||
`flat design icon of sparkly microphone with star decorations, K-pop style shiny handheld mic with glitter and star accents in pink and gold, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_08 -- 烧酒瓶
|
||||
`flat design icon of soju bottle, Korean green glass bottle with white label and matching small shot glass, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_09 -- 太极旗扇
|
||||
`flat design icon of Korean flag fan with taeguk symbol, traditional folding fan featuring red and blue yin-yang circle and black trigrams, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_10 -- 银杏
|
||||
`flat design icon of ginkgo leaf, golden yellow fan-shaped ginkgo biloba leaf with delicate veins, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_11 -- 柿子
|
||||
`flat design icon of Korean persimmon, bright orange ripe persimmon fruit with green calyx on top, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### seoul_12 -- 松树
|
||||
`flat design icon of Korean pine tree, traditional twisted green pine tree with characteristic spreading branches like in Korean paintings, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### cat_seoul -- 韩韩(猫猫)
|
||||
`cute Korean Bobtail cat, cream and beige fur with short stubby tail, wearing tiny Korean traditional hat gat black cylindrical hat, sitting pose with big round eyes, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### stamp_seoul -- 首尔邮票
|
||||
`flat design postage stamp of Gyeongbokgung Palace, traditional Korean palace building, taeguk red and blue color palette, decorative stamp border with perforated edges, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 新加坡 Singapore(12 元素 + 1 猫猫 + 1 邮票 = 14 张)
|
||||
|
||||
### singapore_01 -- 鱼尾狮
|
||||
`flat design icon of Merlion statue spouting water, iconic Singapore half-lion half-fish white statue with water fountain from mouth, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_02 -- 金沙酒店
|
||||
`flat design icon of Marina Bay Sands building, three towers with boat-shaped rooftop SkyPark, modern architecture landmark, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_03 -- 叻沙
|
||||
`flat design icon of laksa spicy noodle soup, bowl of rich orange-red coconut curry broth with noodles and prawns, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_04 -- 辣椒螃蟹
|
||||
`flat design icon of Singapore chili crab on plate, whole red crab covered in thick glossy red chili sauce on white plate, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_05 -- 咖椰吐司
|
||||
`flat design icon of kaya toast with butter and soft-boiled egg, two slices of toasted bread with green kaya jam and butter pat alongside a half-boiled egg in saucer, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_06 -- 娘惹花砖
|
||||
`flat design icon of Peranakan ceramic tile, single square tile with intricate colorful floral geometric pattern in turquoise pink and yellow, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_07 -- 小红点贴纸
|
||||
`flat design icon of red dot badge sticker, circular bright red dot emblem representing Singapore the Little Red Dot, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_08 -- 冰淇淋三明治车
|
||||
`flat design icon of Singapore ice cream sandwich cart, colorful street vendor pushcart with umbrella and ice cream blocks with rainbow bread slices, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_09 -- 组屋钥匙
|
||||
`flat design icon of HDB flat key with keychain, golden house key with a small colorful HDB building charm on keyring, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_10 -- 兰花
|
||||
`flat design icon of Vanda orchid, purple and white Singapore national flower with elegant petals and spotted pattern, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_11 -- 榴莲
|
||||
`flat design icon of durian fruit, spiky yellow-green tropical fruit with one section opened showing creamy yellow flesh inside, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### singapore_12 -- 雨树
|
||||
`flat design icon of rain tree with wide spreading canopy, large tropical tree with broad umbrella-shaped green crown and thick trunk, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### cat_singapore -- 狮喵(猫猫)
|
||||
`cute calico cat, golden and white fur patches, wearing tiny lion mane headdress around head like a fluffy golden collar, sitting pose with big round eyes, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### stamp_singapore -- 新加坡邮票
|
||||
`flat design postage stamp of Merlion statue with water fountain, tropical green and teal color palette with palm trees, decorative stamp border with perforated edges, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 伊斯坦布尔 Istanbul(12 元素 + 1 猫猫 + 1 邮票 = 14 张)
|
||||
|
||||
### istanbul_01 -- 蓝色清真寺
|
||||
`flat design icon of Blue Mosque with large central dome and six tall minarets, iconic Istanbul mosque in blue and white, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_02 -- 加拉塔塔
|
||||
`flat design icon of Galata Tower, medieval stone tower with conical roof top and observation balcony in warm beige and brown, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_03 -- 土耳其红茶
|
||||
`flat design icon of Turkish tea in tulip-shaped glass, clear glass with golden-red tea on small saucer with sugar cubes and tiny spoon, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_04 -- 烤肉串
|
||||
`flat design icon of Turkish kebab on metal skewer, grilled meat chunks alternating with vegetables on a long skewer, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_05 -- 土耳其冰淇淋
|
||||
`flat design icon of Turkish ice cream dondurma on long stick, stretchy white ice cream served on a very long metal stick by vendor, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_06 -- 旋转舞裙
|
||||
`flat design icon of Whirling Dervish white skirt, flowing white spinning skirt of a Sufi dancer with tall brown hat, elegant spiral movement, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_07 -- 地毯纹样
|
||||
`flat design icon of Turkish carpet with geometric pattern, rectangular woven rug with intricate red blue and gold traditional Ottoman patterns and fringed edges, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_08 -- 恶魔之眼
|
||||
`flat design icon of Nazar evil eye blue glass amulet, concentric circles of dark blue light blue white and black forming traditional Turkish protective eye charm, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_09 -- 铜咖啡壶
|
||||
`flat design icon of Turkish copper coffee pot cezve, traditional long-handled small copper pot with wide bottom and narrow neck for brewing Turkish coffee, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_10 -- 郁金香
|
||||
`flat design icon of red tulip flower, single elegant tulip with pointed petals in vibrant red with green stem and leaf, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_11 -- 石榴
|
||||
`flat design icon of pomegranate fruit cut open showing red seeds, half-cut ruby red pomegranate revealing juicy arils inside with whole fruit beside it, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### istanbul_12 -- 海鸥
|
||||
`flat design icon of seagull bird, white and gray Istanbul seagull in flight with wings spread over Bosphorus blue accent, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
### cat_istanbul -- 蓝眸(猫猫)
|
||||
`cute Turkish Angora cat, pure white fluffy long fur, striking blue eyes, wearing evil eye nazar amulet collar with blue beads around neck, sitting pose with elegant bushy tail, cute kawaii style, simple clean lines, sticker style, pure white background, no text, high saturation colors --ar 1:1 --style raw`
|
||||
|
||||
### stamp_istanbul -- 伊斯坦布尔邮票
|
||||
`flat design postage stamp of Blue Mosque silhouette with domes and minarets, Ottoman blue and teal color palette with golden accents, decorative stamp border with perforated edges, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on pure white background --ar 1:1 --style raw`
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 城市 | 元素 | 猫猫 | 邮票 | 合计 |
|
||||
|------|------|------|------|------|
|
||||
| 首尔 | 12 | 1 | 1 | 14 |
|
||||
| 新加坡 | 12 | 1 | 1 | 14 |
|
||||
| 伊斯坦布尔 | 12 | 1 | 1 | 14 |
|
||||
| **总计** | **36** | **3** | **3** | **42** |
|
||||
78
assets/prompts/beijing-3d-prompts.md
Normal file
78
assets/prompts/beijing-3d-prompts.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# 北京 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
> 已完成的:beijing_01(糖葫芦 3D 版)
|
||||
|
||||
---
|
||||
|
||||
## beijing_02 — 京剧脸谱
|
||||
|
||||
```
|
||||
3D rendered icon of Chinese Peking opera face mask, colorful traditional opera mask with red white and black painted patterns, glossy ceramic material with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_03 — 天安门
|
||||
|
||||
```
|
||||
3D rendered icon of Tiananmen Gate, red Chinese gate building with golden yellow glazed tile roof and red lanterns, no portrait or person on the building, miniature architectural model style with realistic materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_04 — 烤鸭
|
||||
|
||||
```
|
||||
3D rendered icon of Peking roast duck, whole golden crispy roasted duck on a white plate, glossy caramelized skin with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_05 — 兔儿爷
|
||||
|
||||
```
|
||||
3D rendered icon of Chinese Tu Er Ye rabbit lord figurine, traditional Beijing painted clay rabbit toy with colorful armor and golden helmet, glossy painted ceramic material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_06 — 长城
|
||||
|
||||
```
|
||||
3D rendered icon of Great Wall of China, miniature section of stone brick wall stretching across green hills with watchtower, realistic stone texture and grass, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_07 — 故宫角楼
|
||||
|
||||
```
|
||||
3D rendered icon of Forbidden City corner watchtower, traditional Chinese palace tower with multi-layered golden roof and red walls, miniature architectural model with realistic glazed tile material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_08 — 豆汁
|
||||
|
||||
```
|
||||
3D rendered icon of Beijing douzhir fermented mung bean drink, pale green liquid in a small white ceramic bowl with green mung beans beside it, realistic liquid surface reflection, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_09 — 铜锣
|
||||
|
||||
```
|
||||
3D rendered icon of Chinese bronze gong, round polished golden brass gong with red rope hanger and wooden mallet, realistic metallic reflection and shine, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_10 — 毛笔
|
||||
|
||||
```
|
||||
3D rendered icon of Chinese calligraphy brush, traditional ink brush with bamboo handle and soft white bristle tip with black ink, beside a small ink stone, realistic material textures, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_11 — 鸟巢
|
||||
|
||||
```
|
||||
3D rendered icon of Beijing National Stadium Bird Nest, miniature modern steel lattice stadium structure in silver gray, realistic metallic material with reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## beijing_12 — 四合院门
|
||||
|
||||
```
|
||||
3D rendered icon of Beijing siheyuan courtyard gate, traditional red Chinese double door with golden lion head knockers and gray tiled roof, realistic wood and stone materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_beijing — 京京猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing orange tabby cat with darker tiger stripes, wearing a traditional Chinese red and gold tiger head hat, big round eyes with cute smiling expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
83
assets/prompts/istanbul-3d-prompts.md
Normal file
83
assets/prompts/istanbul-3d-prompts.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 伊斯坦布尔 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
|
||||
---
|
||||
|
||||
## istanbul_01 — 蓝色清真寺
|
||||
|
||||
```
|
||||
3D rendered icon of Sultan Ahmed Blue Mosque, iconic Istanbul mosque with cascading domes and six tall minarets in white and blue-gray stone, miniature architectural model with realistic stone and lead dome materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_02 — 加拉塔塔
|
||||
|
||||
```
|
||||
3D rendered icon of Galata Tower, tall cylindrical medieval stone tower with conical roof cap and observation balcony near the top, light beige stone texture with realistic weathered material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_03 — 土耳其红茶
|
||||
|
||||
```
|
||||
3D rendered icon of Turkish tea, tulip-shaped clear glass tea cup filled with deep red-amber Turkish cay tea on a small glass saucer with a tiny silver spoon, realistic transparent glass with warm liquid glow, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_04 — 烤肉串
|
||||
|
||||
```
|
||||
3D rendered icon of Turkish kebab skewer, metal skewer with grilled chunks of seasoned lamb meat alternating with red pepper and onion pieces, charred grill marks with realistic juicy meat texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_05 — 土耳其冰淇淋
|
||||
|
||||
```
|
||||
3D rendered icon of Turkish dondurma ice cream, stretchy elastic ice cream swirled on a long metal stick held upright, white and pink striped soft serve with visible stretchy pull texture, sprinkled with crushed pistachios, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_06 — 旋转舞裙
|
||||
|
||||
```
|
||||
3D rendered icon of Whirling Dervish skirt, flowing white spinning robe dress with wide flared hem captured mid-spin, tall brown felt hat on top, elegant fabric folds with realistic cotton material and motion feel, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_07 — 地毯纹样
|
||||
|
||||
```
|
||||
3D rendered icon of Turkish carpet, small ornate woven rug with intricate geometric and floral medallion pattern in deep red navy blue and gold colors, with fringed tassels on edges, realistic wool textile texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_08 — 恶魔之眼
|
||||
|
||||
```
|
||||
3D rendered icon of Nazar evil eye amulet, concentric circles of dark blue light blue white and black forming a traditional Turkish protective eye charm, glossy glass bead material with realistic reflections and depth, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_09 — 铜咖啡壶
|
||||
|
||||
```
|
||||
3D rendered icon of Turkish coffee cezve pot, small traditional long-handled copper coffee pot with hammered texture and ornate engravings, with dark coffee and foam inside, realistic polished copper metallic material with warm reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_10 — 郁金香
|
||||
|
||||
```
|
||||
3D rendered icon of tulip flower, single elegant red tulip with smooth curved petals and green stem with leaves, vibrant saturated color with realistic petal texture and slight translucency, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_11 — 石榴
|
||||
|
||||
```
|
||||
3D rendered icon of pomegranate fruit, deep red ripe pomegranate cracked open to reveal clusters of ruby red jewel-like seeds inside, glossy skin with realistic juice droplets and seed texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## istanbul_12 — 海鸥
|
||||
|
||||
```
|
||||
3D rendered icon of seagull, white and gray seagull bird with orange beak and feet standing in a cute pose with wings slightly spread, realistic feather texture with soft downy detail, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_istanbul — 蓝眸猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing Turkish Angora cat with long fluffy pure white fur coat, wearing a small Nazar evil eye collar charm in blue and white, striking blue eyes with cute smiling expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
83
assets/prompts/seoul-3d-prompts.md
Normal file
83
assets/prompts/seoul-3d-prompts.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 首尔 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
|
||||
---
|
||||
|
||||
## seoul_01 — 景福宫
|
||||
|
||||
```
|
||||
3D rendered icon of Gyeongbokgung Palace, traditional Korean royal palace with curved tiled roof in green and red, white stone base with red wooden pillars and colorful dancheong patterns, miniature architectural model with realistic materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_02 — 南山塔
|
||||
|
||||
```
|
||||
3D rendered icon of N Seoul Tower on Namsan Mountain, tall white communication tower with observation deck on top of a green forested hill, miniature landmark model with realistic metallic and concrete materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_03 — 石锅拌饭
|
||||
|
||||
```
|
||||
3D rendered icon of Korean bibimbap in stone pot, hot stone bowl filled with white rice topped with colorful vegetables carrots spinach bean sprouts and a fried egg, with red gochujang sauce on top, realistic food textures and steam, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_04 — 韩式炸鸡
|
||||
|
||||
```
|
||||
3D rendered icon of Korean fried chicken, crispy golden fried chicken pieces coated in glossy red-orange sweet chili sauce on a small plate, realistic crispy batter texture with sauce glaze, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_05 — 海苔卷
|
||||
|
||||
```
|
||||
3D rendered icon of Korean gimbap seaweed rice roll, sliced rounds of rice wrapped in dark green seaweed showing colorful fillings of yellow pickled radish egg and vegetables inside, on a small white plate, realistic food textures, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_06 — 韩服
|
||||
|
||||
```
|
||||
3D rendered icon of Korean hanbok dress, traditional women's hanbok with pastel pink jeogori top jacket and long flowing blue chima skirt with ribbon bow at chest, elegant fabric folds with realistic silk material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_07 — K-pop话筒
|
||||
|
||||
```
|
||||
3D rendered icon of K-pop microphone, sparkly pink and silver handheld microphone with holographic glitter finish and a small star decoration, glossy metallic material with colorful light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_08 — 烧酒瓶
|
||||
|
||||
```
|
||||
3D rendered icon of Korean soju bottle, small green glass bottle with white label and blue text, beside a tiny clear shot glass, realistic transparent glass material with light refraction, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_09 — 太极旗扇
|
||||
|
||||
```
|
||||
3D rendered icon of Korean taegeuk folding fan, traditional paper folding fan with red and blue taegeuk yin-yang symbol and black trigram patterns on white background, realistic paper and bamboo material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_10 — 银杏
|
||||
|
||||
```
|
||||
3D rendered icon of ginkgo leaf, single golden yellow fan-shaped ginkgo biloba leaf with delicate vein patterns, realistic translucent leaf material with autumn color, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_11 — 柿子
|
||||
|
||||
```
|
||||
3D rendered icon of persimmon fruit, bright orange-red ripe persimmon with green calyx leaves on top, smooth glossy skin with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## seoul_12 — 松树
|
||||
|
||||
```
|
||||
3D rendered icon of Korean pine tree, small elegant curved pine tree with dark green needle clusters and reddish-brown textured bark trunk in classic Asian bonsai-like shape, realistic foliage and wood material, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_seoul — 韩韩猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing blue-gray short-haired cat with silver coat, wearing a small traditional Korean hanbok hat gat in black, big round eyes with cute smiling expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
83
assets/prompts/singapore-3d-prompts.md
Normal file
83
assets/prompts/singapore-3d-prompts.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 新加坡 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
|
||||
---
|
||||
|
||||
## singapore_01 — 鱼尾狮
|
||||
|
||||
```
|
||||
3D rendered icon of Merlion statue, white stone mythical creature with lion head spouting water from mouth and fish tail body, standing on a wave-shaped pedestal, realistic stone marble material with water splash, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_02 — 金沙酒店
|
||||
|
||||
```
|
||||
3D rendered icon of Marina Bay Sands hotel, three tall curved tower buildings connected by a boat-shaped rooftop sky park on top, modern glass and steel architecture in silver and gold tones, miniature architectural model with realistic materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_03 — 叻沙
|
||||
|
||||
```
|
||||
3D rendered icon of Singapore laksa, bowl of spicy coconut curry noodle soup in rich orange-red broth with thick rice noodles shrimp tofu puffs and bean sprouts, topped with laksa leaf garnish, realistic food textures with glossy soup surface, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_04 — 辣椒螃蟹
|
||||
|
||||
```
|
||||
3D rendered icon of Singapore chilli crab, whole red crab covered in thick glossy sweet chili sauce on a white plate, bright red shell with realistic sauce glaze and steam, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_05 — 咖椰吐司
|
||||
|
||||
```
|
||||
3D rendered icon of kaya toast set, two slices of crispy toasted bread with green kaya coconut jam and butter filling, beside a soft-boiled egg in a small sauce dish and a cup of kopi coffee, realistic bread texture and melted butter, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_06 — 娘惹花砖
|
||||
|
||||
```
|
||||
3D rendered icon of Peranakan ceramic tile, square decorative tile with intricate floral pattern in turquoise pink and yellow pastel colors, traditional Peranakan Nyonya geometric motif with ornate border, glossy glazed ceramic surface with realistic reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_07 — 小红点贴纸
|
||||
|
||||
```
|
||||
3D rendered icon of Singapore Little Red Dot badge sticker, round red circle badge with white crescent moon and five stars Singapore emblem, glossy enamel pin material with metallic edge, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_08 — 冰淇淋三明治车
|
||||
|
||||
```
|
||||
3D rendered icon of Singapore ice cream sandwich cart, small colorful street vendor pushcart with rainbow umbrella on top and stacked ice cream blocks in pastel colors, retro nostalgic style with realistic metal and fabric materials, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_09 — 组屋钥匙
|
||||
|
||||
```
|
||||
3D rendered icon of HDB flat key, golden brass house key with a small keychain tag shaped like a Singapore HDB public housing building, realistic metallic material with shine and reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_10 — 兰花
|
||||
|
||||
```
|
||||
3D rendered icon of Vanda Miss Joaquim orchid, elegant purple and pink Singapore national orchid flower with spotted petals and golden center, delicate realistic petal texture with slight translucency, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_11 — 榴莲
|
||||
|
||||
```
|
||||
3D rendered icon of durian fruit, large spiky tropical fruit with sharp green-brown thorny shell cracked open to reveal creamy golden yellow flesh inside, realistic spiky texture and soft custard interior, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## singapore_12 — 雨树
|
||||
|
||||
```
|
||||
3D rendered icon of rain tree, wide spreading tropical tree with broad flat umbrella-shaped canopy of lush green feathery leaves and dark brown trunk, realistic foliage and bark texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_singapore — 狮喵猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing golden calico cat with warm orange and white patches, wearing a small cute lion mane collar in golden brown fluffy fur around the face, big round eyes with cute smiling expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
83
assets/prompts/tokyo-3d-prompts.md
Normal file
83
assets/prompts/tokyo-3d-prompts.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# 东京 3D 素材 Prompt — 逐条复制
|
||||
|
||||
> 一条一条发给 ChatGPT,每条生成 1 张图,自动保存到下载目录。
|
||||
|
||||
---
|
||||
|
||||
## tokyo_01 — 东京塔
|
||||
|
||||
```
|
||||
3D rendered icon of Tokyo Tower, miniature red and white lattice steel tower with observation deck, iconic Japanese landmark, realistic metallic paint material with light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_02 — 鸟居
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese torii gate, traditional vermilion red Shinto shrine gate with two pillars and curved top beam, realistic lacquered wood material with subtle grain, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_03 — 寿司
|
||||
|
||||
```
|
||||
3D rendered icon of salmon nigiri sushi, fresh orange salmon slice draped over white rice block, glossy surface with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_04 — 拉面
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese ramen bowl, white ceramic bowl filled with rich golden tonkotsu broth, wavy noodles, sliced chashu pork, soft-boiled egg half and green onion, realistic liquid surface and steam, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_05 — 章鱼烧
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese takoyaki octopus balls, three golden brown round dumplings on a small paper boat tray, drizzled with dark sauce and mayonnaise, topped with bonito flakes and green seaweed, glossy surface with realistic light reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_06 — 招财猫
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese maneki-neko lucky cat figurine, white ceramic cat sitting upright with one paw raised, golden bell collar, painted red ear details and big friendly eyes, glossy porcelain material with realistic reflections, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_07 — 浮世绘
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese ukiyo-e woodblock print, miniature framed artwork showing the iconic Great Wave style with blue and white ocean waves, wooden frame with realistic grain texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_08 — 和服扇
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese folding fan, traditional sensu fan fully opened showing red and pink cherry blossom pattern on white and gold fabric, realistic paper and bamboo rib texture, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_09 — 新干线票
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese Shinkansen bullet train ticket, small rectangular paper ticket with blue stripe and perforated edge, beside a miniature white and blue bullet train nose, realistic paper texture and metallic train surface, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_10 — 樱花
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese cherry blossom branch, delicate pink sakura flowers in full bloom on a small brown twig, soft translucent petals with subtle gradient from white to pink, a few falling petals, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_11 — 富士山
|
||||
|
||||
```
|
||||
3D rendered icon of Mount Fuji, miniature symmetric volcanic mountain with snow-capped white peak and blue-gray slopes, small white clouds around the summit, realistic snow and rock textures, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## tokyo_12 — 锦鲤
|
||||
|
||||
```
|
||||
3D rendered icon of Japanese koi fish, elegant orange and white nishikigoi carp with flowing fins and tail, shimmering scales with realistic iridescent reflections, graceful swimming pose, clay render style with soft shadows and ambient occlusion, slight top-down perspective, warm studio lighting, rounded corners, game asset style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
|
||||
## cat_tokyo — 樱丸猫猫
|
||||
|
||||
```
|
||||
3D rendered cute cat head icon, front-facing pure white cat with soft fluffy fur, wearing a small golden Japanese bell on a red collar, big round sparkling eyes with cute gentle expression, soft fur texture with realistic subsurface scattering, clay render style with soft shadows and ambient occlusion, warm studio lighting, game character style, high quality, no text, isolated on light gray background
|
||||
```
|
||||
161
docs/art-production-guide.md
Normal file
161
docs/art-production-guide.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 美术生产指南 — 城市抓猫猫
|
||||
|
||||
## 1. 总体风格定义
|
||||
|
||||
风格关键词:扁平冰箱贴风(Flat fridge magnet style)
|
||||
|
||||
- 圆角矩形白底卡片
|
||||
- 高饱和度彩色图标
|
||||
- 1px 浅灰描边
|
||||
- 微投影(轻微立体感)
|
||||
- 简洁线条,无复杂细节
|
||||
- 可爱、清新、适合小尺寸显示
|
||||
|
||||
参考风格:旅行冰箱贴、emoji sticker pack、Notion 风格图标
|
||||
|
||||
## 2. 素材类型与规格
|
||||
|
||||
| 素材类型 | 尺寸 | 格式 | 数量/城市 | AI Prompt 后缀 |
|
||||
|---------|------|------|----------|---------------|
|
||||
| 元素图标 | 128×128px @2x | PNG 透明底 | 12-15 个 | 见模板 A |
|
||||
| 猫猫封面 | 256×256px @2x | PNG 透明底 | 1 个 | 见模板 B |
|
||||
| 猫猫缩略图 | 128×128px @2x | PNG 透明底 | 1 个 | 同封面缩小 |
|
||||
| 冰箱贴(收集品版) | 128×128px @2x | PNG 透明底 | 6 个/城市 | 同元素图标 |
|
||||
| 城市邮票 | 192×256px @2x | PNG 透明底 | 1 个/城市 | 见模板 C |
|
||||
| 地区猫猫 | 256×256px @2x | PNG 透明底 | 8 个(中国) | 见模板 D |
|
||||
| 国家动物 | 256×256px @2x | PNG 透明底 | 按国家 | 见模板 E |
|
||||
| 洲地图 | 512×512px @2x | PNG 透明底 | 6 张 | 见模板 F |
|
||||
|
||||
## 3. Prompt 模板
|
||||
|
||||
### 模板 A — 城市元素图标
|
||||
|
||||
```
|
||||
Base prompt:
|
||||
flat design icon of [物品名], [物品描述], cute sticker style,
|
||||
rounded rectangle white background with thin light gray border,
|
||||
subtle drop shadow, high saturation colors, simple clean lines,
|
||||
no text, isolated on white background, 128x128px
|
||||
|
||||
Color guidance: [城市主色调] accent
|
||||
|
||||
Negative prompt:
|
||||
realistic, 3D render, photograph, complex details, text, watermark, blurry, dark
|
||||
```
|
||||
|
||||
示例 — 北京糖葫芦:
|
||||
|
||||
```
|
||||
flat design icon of Chinese tanghulu (candied hawthorn on stick), red glossy berries on wooden stick, cute sticker style, rounded rectangle white background with thin light gray border, subtle drop shadow, high saturation colors, simple clean lines, no text, isolated on white background
|
||||
```
|
||||
|
||||
### 模板 B — 城市猫猫封面
|
||||
|
||||
```
|
||||
Base prompt:
|
||||
flat design cat head icon, front-facing [毛色] cat with [花纹] pattern,
|
||||
wearing [装饰], [表情] expression, cute kawaii style,
|
||||
simple clean lines, sticker style, white background, no text,
|
||||
high saturation colors, 256x256px
|
||||
|
||||
Negative prompt:
|
||||
realistic, photograph, full body, complex background, text, watermark
|
||||
```
|
||||
|
||||
示例 — 北京猫猫(京京):
|
||||
|
||||
```
|
||||
flat design cat head icon, front-facing orange tabby cat with darker orange stripes, wearing a traditional Chinese tiger hat (虎头帽), cute smiling expression, kawaii style, simple clean lines, sticker style, white background, no text, high saturation colors
|
||||
```
|
||||
|
||||
### 模板 C — 城市邮票
|
||||
|
||||
```
|
||||
flat design postage stamp of [城市名], vintage stamp border with perforated edges,
|
||||
[城市标志元素] in center, city name "[英文名]" at bottom,
|
||||
[城市主色调] color scheme, retro travel poster style,
|
||||
simple clean illustration, no photograph, white background
|
||||
```
|
||||
|
||||
### 模板 D — 地区猫猫(中国)
|
||||
|
||||
```
|
||||
flat design cat head icon, front-facing [品种] cat,
|
||||
[毛色和花纹描述], wearing [地区特色装饰],
|
||||
cute kawaii style, simple clean lines, sticker style,
|
||||
white background, no text, high saturation colors
|
||||
```
|
||||
|
||||
### 模板 E — 国家代表动物
|
||||
|
||||
```
|
||||
flat design icon of [动物名], cute cartoon style,
|
||||
wearing [国家特色小装饰], [国旗配色 accent],
|
||||
simple clean lines, sticker style, rounded rectangle white background,
|
||||
thin light gray border, subtle drop shadow, no text, isolated on white
|
||||
```
|
||||
|
||||
### 模板 F — 洲地图
|
||||
|
||||
```
|
||||
flat design illustrated map of [洲名], [风格描述],
|
||||
showing continent outline with cute landmark icons,
|
||||
[色调] color scheme, hand-drawn travel map style,
|
||||
simple clean illustration, white background, no text labels
|
||||
```
|
||||
|
||||
## 4. 色彩参照
|
||||
|
||||
| 城市 | 主色 | HEX | 应用 |
|
||||
|------|------|-----|------|
|
||||
| 北京 | 中国红 | #CC2936 | 元素边框高亮、邮票底色 |
|
||||
| 东京 | 樱花粉红 | #E84057 | — |
|
||||
| 曼谷 | 泰国金 | #FFB347 | — |
|
||||
| 首尔 | 太极蓝 | #4A90D9 | — |
|
||||
| 新加坡 | 热带绿 | #00A896 | — |
|
||||
| 伊斯坦布尔 | 奥斯曼蓝 | #1A5276 | — |
|
||||
|
||||
## 5. AI 工具推荐
|
||||
|
||||
| 工具 | 优势 | 推荐用途 |
|
||||
|------|------|---------|
|
||||
| Midjourney V6 | 风格一致性最好 | 元素图标、猫猫 |
|
||||
| DALL-E 3 | Prompt 理解力强 | 邮票、地图 |
|
||||
| Stable Diffusion + ControlNet | 可控性最高 | 批量统一风格 |
|
||||
|
||||
## 6. 后处理 Checklist
|
||||
|
||||
每张图 AI 出图后需要人工修正:
|
||||
|
||||
- [ ] 去除多余背景(确保透明底)
|
||||
- [ ] 统一尺寸裁切(元素 128px / 猫猫 256px)
|
||||
- [ ] 检查线条粗细一致性
|
||||
- [ ] 调整饱和度到统一水平
|
||||
- [ ] 添加白底圆角矩形卡片背景(如果 AI 没生成)
|
||||
- [ ] 添加 1px 浅灰描边 + 微投影
|
||||
- [ ] 导出 @2x PNG
|
||||
- [ ] 按命名规范重命名
|
||||
|
||||
## 7. 命名规范
|
||||
|
||||
```
|
||||
元素图标: images/elements/{cityId}/{cityId}_{序号}.png
|
||||
猫猫封面: images/cats/cat_{cityId}.png
|
||||
猫猫缩略图:images/cats/cat_{cityId}_thumb.png
|
||||
冰箱贴: images/magnets/{cityId}/magnet_{cityId}_{levelId}.png
|
||||
邮票: images/stamps/stamp_{cityId}.png
|
||||
地区猫猫: images/cats/region/cat_{regionId}.png
|
||||
国家动物: images/animals/animal_{countryId}.png
|
||||
洲地图: images/maps/map_{continentId}.png
|
||||
```
|
||||
|
||||
## 8. 生产优先级
|
||||
|
||||
| 优先级 | 素材 | 数量 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| P0 | 北京 14 元素 + 猫猫 | 15 张 | 首城,开发验证用 |
|
||||
| P0 | 通用 UI(按钮、槽位、背景) | ~10 张 | 游戏核心界面 |
|
||||
| P1 | 其他 5 城市元素 + 猫猫 | ~75 张 | MVP 完整内容 |
|
||||
| P1 | 6 城市邮票 | 6 张 | 收集系统 |
|
||||
| P2 | 音效 | 6-8 个 | 通用交互音 |
|
||||
| P3 | 地区猫猫、国家动物、洲地图 | ~20 张 | V1.1+ |
|
||||
@@ -0,0 +1,67 @@
|
||||
# Gift Zone CityTeam Mashup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add the MVP cat album and local cityTeam entry flow, then wire the mashup home tile into a playable standalone round with local rewards.
|
||||
|
||||
**Architecture:** Extend the existing `contentSystem` and `scene-store` contracts instead of refactoring core navigation. Reuse the current gameplay session for mashup by feeding it generated board metadata and handling mashup completion in a separate local reward path.
|
||||
|
||||
**Tech Stack:** ES modules, Node test runner, WeChat mini-game canvas shell
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Docs And Data Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md`
|
||||
- Modify: `js/content/index.js`
|
||||
|
||||
- [ ] Define the MVP cat album contract in `contentSystem`
|
||||
- [ ] Keep `animals` and `maps` out of scope
|
||||
- [ ] Add any helper projections needed for cityTeam and mashup
|
||||
|
||||
### Task 2: Gift Zone And CityTeam
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/content-system.test.js`
|
||||
- Modify: `tests/scene-store.test.js`
|
||||
- Modify: `js/content/index.js`
|
||||
- Modify: `js/ui/scene-store.js`
|
||||
- Modify: `js/main.js`
|
||||
|
||||
- [ ] Write failing tests for `cats` album visibility and counts
|
||||
- [ ] Write failing tests for cityTeam selection flow
|
||||
- [ ] Implement `cats` album projections with existing city cat data
|
||||
- [ ] Implement `city-team-select` scene and local selection writeback
|
||||
- [ ] Render cityTeam status and entry buttons in the canvas UI
|
||||
- [ ] Re-run targeted tests until green
|
||||
|
||||
### Task 3: Mashup Gameplay
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/difficulty-generator.test.js`
|
||||
- Modify: `tests/scene-store.test.js`
|
||||
- Modify: `js/gameplay/difficulty/generate-board.js`
|
||||
- Create: `js/gameplay/difficulty/generate-mashup-board.js`
|
||||
- Modify: `js/gameplay/difficulty/index.js`
|
||||
- Modify: `js/gameplay/session/index.js`
|
||||
- Modify: `js/ui/scene-store.js`
|
||||
- Modify: `js/main.js`
|
||||
|
||||
- [ ] Write failing tests for mashup board generation
|
||||
- [ ] Write failing tests for mashup scene entry and reward writeback
|
||||
- [ ] Implement mashup board builder from unlocked city content
|
||||
- [ ] Implement mashup scene/session creation in `scene-store`
|
||||
- [ ] Implement mashup win reward path and result messaging
|
||||
- [ ] Re-run targeted tests until green
|
||||
|
||||
### Task 4: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/*` as needed
|
||||
- Modify: `js/*` as needed
|
||||
|
||||
- [ ] Run the full test suite with `npm test`
|
||||
- [ ] Verify no unrelated placeholders were implemented
|
||||
- [ ] Summarize remaining placeholder areas in the final report
|
||||
@@ -0,0 +1,92 @@
|
||||
# Gift Zone CityTeam Mashup Design
|
||||
|
||||
## Goal
|
||||
|
||||
在现有 MVP 壳子上补齐三个紧邻切片:
|
||||
|
||||
- 礼物区从 2 册扩到 3 册,先把 `猫猫册` 做成可浏览的 MVP 版本
|
||||
- 接通本地 `cityTeam` 选队入口,只做本地选择和展示,不做服务端排行
|
||||
- 把 `主题大混战` 做成可进入的独立对局,并提供本地奖励回写
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- `gift-zone` 增加 `cats` tab 和汇总计数
|
||||
- `cityTeam` 选择页、本地入队状态、入口按钮
|
||||
- `mashup` 入口从首页可进入独立对局
|
||||
- `mashup` 使用已解锁城市元素混搭生成棋盘
|
||||
- `mashup` 通关奖励写回本地存档
|
||||
|
||||
### Out
|
||||
|
||||
- 真正的分享卡片视觉内容
|
||||
- 开房 PK
|
||||
- 城市战队排行榜服务端逻辑
|
||||
- 礼物区 `动物册 / 地图册`
|
||||
- 地区级猫猫收集规则
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. 猫猫册口径
|
||||
|
||||
先用“已通关城市猫”作为 `猫猫册` MVP 口径,而不是文档里的“地区猫”正式口径。
|
||||
|
||||
原因:
|
||||
|
||||
- 现有存档里已经有 `collectedCats[]` 城市完成态
|
||||
- 现有 CityManifest 已经有 `cat` 和 `cover.catThumb`
|
||||
- 这样可以不引入新资源、不改通关发奖链路
|
||||
|
||||
后续若升级为“地区猫”,只需要替换 `cats` album 的数据投影层,不必重写礼物区 UI 框架。
|
||||
|
||||
### 2. cityTeam 入口
|
||||
|
||||
`cityTeam` 入口同时挂在两个地方:
|
||||
|
||||
- 礼物区顶部信息卡
|
||||
- 主页右侧“排行榜”卡片
|
||||
|
||||
如果未选队,进入 `city-team-select` 选择页;如果已选队,则展示当前所属战队的本地状态说明。选择仅允许已解锁城市,首次选择后本地锁定。
|
||||
|
||||
### 3. 主题大混战实现方式
|
||||
|
||||
不把 `mashup` 硬塞进现有城市关卡合同,而是新增一条轻量 mode 路径:
|
||||
|
||||
- `scene-store` 增加 `mashup` scene
|
||||
- 生成器增加 mashup board 构建辅助函数
|
||||
- `gameplay` 复用现有 `GameSession`
|
||||
- `main.js` 根据 scene metadata 渲染 mashup 标题、结果和奖励文案
|
||||
|
||||
这样不需要把 `contentSystem.getCity()` 改成兼容“虚拟城市”,能控制改动面。
|
||||
|
||||
### 4. 混战奖励
|
||||
|
||||
混战不计入城市关卡进度。通关奖励规则:
|
||||
|
||||
- 优先随机发一个当前已解锁城市的冰箱贴
|
||||
- 如果随机命中的冰箱贴已拥有,则发 `shuffle +1`
|
||||
|
||||
奖励完全本地化,只做存档回写和结果展示。
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Gift Zone
|
||||
|
||||
`playerState` -> `contentSystem.getGiftAlbums()` / `getGiftAlbumEntries()` -> `scene-store gift-zone` -> `main.js` 礼物区页面
|
||||
|
||||
### CityTeam
|
||||
|
||||
`playerState.cityTeam` -> `scene-store` 入口判断 / 选择写回 -> `main.js` 选择页与状态卡
|
||||
|
||||
### Mashup
|
||||
|
||||
首页 `mashup` tile -> `scene-store.openHomeTile('mashup')` -> 构建 mashup board + session -> `main.js` gameplay -> 通关后 `scene-store.completeMashupRun()`
|
||||
|
||||
## Testing
|
||||
|
||||
- 内容系统测试:礼物区 album 列表包含 `cats`,计数与条目投影正确
|
||||
- scene-store 测试:cityTeam 选择页可打开、可选择、选后锁定
|
||||
- scene-store 测试:`mashup` 在通关 2 城后可进入
|
||||
- 对局/难度测试:mashup board 由多个城市元素组成且可复现
|
||||
- scene-store 测试:mashup 通关可写入奖励
|
||||
45
js/content/default-player-state.js
Normal file
45
js/content/default-player-state.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export function createDefaultPlayerState() {
|
||||
return {
|
||||
saveVersion: 2,
|
||||
unlockedCities: ['beijing'],
|
||||
levelProgress: {},
|
||||
collectedCats: [],
|
||||
collectedMagnets: [],
|
||||
collectedStamps: [],
|
||||
cityTeam: {
|
||||
teamCityId: null,
|
||||
joinedDate: null,
|
||||
lastSwitchDate: null,
|
||||
},
|
||||
passportStamps: [],
|
||||
inventory: {
|
||||
undo: 3,
|
||||
remove: 1,
|
||||
shuffle: 1,
|
||||
},
|
||||
dailyChallenge: {
|
||||
date: '',
|
||||
completed: false,
|
||||
cityId: 'beijing',
|
||||
seed: 0,
|
||||
},
|
||||
adCooldowns: {
|
||||
interstitialCount: 0,
|
||||
lastInterstitialTime: 0,
|
||||
lastRewardDate: '',
|
||||
},
|
||||
settings: {
|
||||
soundEnabled: true,
|
||||
musicEnabled: true,
|
||||
vibrationEnabled: true,
|
||||
},
|
||||
stats: {
|
||||
totalGamesPlayed: 0,
|
||||
totalGamesWon: 0,
|
||||
totalShareCount: 0,
|
||||
firstPlayDate: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default createDefaultPlayerState
|
||||
286
js/content/index.js
Normal file
286
js/content/index.js
Normal file
@@ -0,0 +1,286 @@
|
||||
import { continents } from './continents/index.js'
|
||||
import cities from './cities/index.js'
|
||||
import { createDefaultPlayerState } from './default-player-state.js'
|
||||
import { getCatalogChildren } from './navigation/world-catalog.js'
|
||||
import { getEnabledRoots, getNavNode as getRuntimeNavNode } from './navigation/runtime-nav.js'
|
||||
import { createBundleResolver } from './registry/bundle-resolver.js'
|
||||
import { createCityRegistry } from './registry/city-registry.js'
|
||||
import { createContinentRegistry } from './registry/continent-registry.js'
|
||||
import { projectCityCardView, projectCityProgress } from './registry/progress-projector.js'
|
||||
import { validateAssets } from './validation/validate-assets.js'
|
||||
import { validateCity } from './validation/validate-city.js'
|
||||
import { validateContinent } from './validation/validate-continent.js'
|
||||
|
||||
export function validateContent(options = {}) {
|
||||
const continentIds = new Set(continents.map((continent) => continent.id))
|
||||
const cityIds = new Set(cities.map((city) => city.id))
|
||||
const errors = []
|
||||
const warnings = []
|
||||
|
||||
for (const continent of continents) {
|
||||
const result = validateContinent(continent, cityIds)
|
||||
errors.push(...result.errors)
|
||||
warnings.push(...result.warnings)
|
||||
}
|
||||
|
||||
for (const city of cities) {
|
||||
const result = validateCity(city, continentIds, cityIds)
|
||||
errors.push(...result.errors)
|
||||
warnings.push(...result.warnings)
|
||||
|
||||
const assetResult = validateAssets(city, options.assetExists)
|
||||
errors.push(...assetResult.errors)
|
||||
warnings.push(...assetResult.warnings)
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export function createContentSystem() {
|
||||
const continentRegistry = createContinentRegistry(continents)
|
||||
const cityRegistry = createCityRegistry(cities)
|
||||
const bundleResolver = createBundleResolver(cityRegistry)
|
||||
const homeRootNodes = getCatalogChildren(null)
|
||||
const giftAlbumDefinitions = [
|
||||
{ id: 'magnets', name: '冰箱贴册' },
|
||||
{ id: 'stamps', name: '邮票册' },
|
||||
{ id: 'cats', name: '猫猫册' },
|
||||
]
|
||||
|
||||
function getCompletedCityCount(playerState) {
|
||||
return cityRegistry
|
||||
.getAllCities()
|
||||
.filter((city) => projectCityProgress(city, playerState).isCompleted)
|
||||
.length
|
||||
}
|
||||
|
||||
function projectNavNode(node) {
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...node,
|
||||
isUnlocked: node.isUnlockedByDefault,
|
||||
}
|
||||
}
|
||||
|
||||
function projectHomeTile(node, playerState) {
|
||||
const completedCityCount = getCompletedCityCount(playerState)
|
||||
const isMashupTile = node.id === 'mashup'
|
||||
const isComingSoonTile = node.id === 'coming_soon'
|
||||
const isUnlocked = isMashupTile
|
||||
? completedCityCount >= 2
|
||||
: node.status === 'active' && node.isUnlockedByDefault
|
||||
const isInteractive = isComingSoonTile || isMashupTile || node.type === 'continent'
|
||||
|
||||
return {
|
||||
...node,
|
||||
isUnlocked,
|
||||
isInteractive,
|
||||
isSpecial: isMashupTile || isComingSoonTile,
|
||||
}
|
||||
}
|
||||
|
||||
function getMagnetEntries(playerState) {
|
||||
const magnets = playerState.collectedMagnets ?? []
|
||||
|
||||
return cityRegistry.getAllCities().map((city) => {
|
||||
const collectedCount = magnets.filter((entry) => entry.cityId === city.id).length
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
cityNameEn: city.display.nameEn,
|
||||
themeColor: city.display.bgColor,
|
||||
collectedCount,
|
||||
totalCount: city.levelPresets.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getStampEntries(playerState) {
|
||||
const stamps = playerState.collectedStamps ?? []
|
||||
|
||||
return cityRegistry.getAllCities().map((city) => {
|
||||
const stampId = city.passport?.stampId ?? `stamp_${city.id}`
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
cityNameEn: city.display.nameEn,
|
||||
themeColor: city.display.bgColor,
|
||||
stampId,
|
||||
isCollected: stamps.some((entry) => entry.stampId === stampId),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCatEntries(playerState) {
|
||||
const collectedCats = new Set(playerState.collectedCats ?? [])
|
||||
|
||||
return cityRegistry.getAllCities().map((city) => ({
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
cityNameEn: city.display.nameEn,
|
||||
themeColor: city.display.bgColor,
|
||||
catId: city.cat.id,
|
||||
catName: city.cat.name,
|
||||
catThumb: city.cover.catThumb,
|
||||
isCollected: collectedCats.has(city.id),
|
||||
}))
|
||||
}
|
||||
|
||||
function summarizeAlbumEntries(albumId, entries) {
|
||||
if (albumId === 'magnets') {
|
||||
return {
|
||||
collectedCount: entries.reduce((sum, entry) => sum + entry.collectedCount, 0),
|
||||
totalCount: entries.reduce((sum, entry) => sum + entry.totalCount, 0),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collectedCount: entries.filter((entry) => entry.isCollected).length,
|
||||
totalCount: entries.length,
|
||||
}
|
||||
}
|
||||
|
||||
function getGiftAlbumEntries(albumId, playerState) {
|
||||
if (albumId === 'magnets') {
|
||||
return getMagnetEntries(playerState)
|
||||
}
|
||||
|
||||
if (albumId === 'stamps') {
|
||||
return getStampEntries(playerState)
|
||||
}
|
||||
|
||||
if (albumId === 'cats') {
|
||||
return getCatEntries(playerState)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getGiftAlbums(playerState) {
|
||||
return giftAlbumDefinitions.map((definition) => {
|
||||
const entries = getGiftAlbumEntries(definition.id, playerState)
|
||||
const summary = summarizeAlbumEntries(definition.id, entries)
|
||||
|
||||
return {
|
||||
...definition,
|
||||
collectedCount: summary.collectedCount,
|
||||
totalCount: summary.totalCount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function projectCityNavEntry(cityId, parentId, playerState) {
|
||||
const city = cityRegistry.getCity(cityId)
|
||||
if (!city) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...projectCityCardView(city, playerState),
|
||||
id: city.id,
|
||||
type: 'city',
|
||||
parentId,
|
||||
themeColor: city.display.bgColor,
|
||||
childType: null,
|
||||
childIds: [],
|
||||
pageSize: 0,
|
||||
status: 'active',
|
||||
isUnlockedByDefault: cityId === 'beijing',
|
||||
sortOrder: city.sortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
function getNavChildren(nodeId, playerState) {
|
||||
const node = getRuntimeNavNode(nodeId)
|
||||
if (!node || !node.childType) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (node.childType === 'city') {
|
||||
return node.childIds
|
||||
.map((cityId) => projectCityNavEntry(cityId, node.id, playerState))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return node.childIds
|
||||
.map((childId) => projectNavNode(getRuntimeNavNode(childId)))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return {
|
||||
getContinentList() {
|
||||
return continentRegistry.getContinentList()
|
||||
},
|
||||
getContinent(continentId) {
|
||||
return continentRegistry.getContinent(continentId)
|
||||
},
|
||||
getCity(cityId) {
|
||||
return cityRegistry.getCity(cityId)
|
||||
},
|
||||
listCitiesByContinent(continentId) {
|
||||
return cityRegistry.listCitiesByContinent(continentId)
|
||||
},
|
||||
getLevelPreset(cityId, levelId) {
|
||||
return cityRegistry.getLevelPreset(cityId, levelId)
|
||||
},
|
||||
getCityProgress(cityId, playerState) {
|
||||
const city = cityRegistry.getCity(cityId)
|
||||
return city ? projectCityProgress(city, playerState) : null
|
||||
},
|
||||
getCityCardView(cityId, playerState) {
|
||||
const city = cityRegistry.getCity(cityId)
|
||||
return city ? projectCityCardView(city, playerState) : null
|
||||
},
|
||||
listCityCards(continentId, playerState) {
|
||||
const navChildren = getNavChildren(continentId, playerState)
|
||||
if (navChildren.length > 0) {
|
||||
return navChildren.filter((entry) => entry.type === 'city')
|
||||
}
|
||||
|
||||
return cityRegistry
|
||||
.listCitiesByContinent(continentId)
|
||||
.map((city) => projectCityCardView(city, playerState))
|
||||
},
|
||||
getRootNavNodes() {
|
||||
return getEnabledRoots().map((node) => projectNavNode(node))
|
||||
},
|
||||
getHomeTiles(playerState) {
|
||||
return homeRootNodes.map((node) => projectHomeTile(node, playerState))
|
||||
},
|
||||
getHomeTile(tileId, playerState) {
|
||||
const node = homeRootNodes.find((entry) => entry.id === tileId) ?? null
|
||||
return node ? projectHomeTile(node, playerState) : null
|
||||
},
|
||||
getGiftAlbums(playerState) {
|
||||
return getGiftAlbums(playerState)
|
||||
},
|
||||
getGiftAlbumEntries(albumId, playerState) {
|
||||
return getGiftAlbumEntries(albumId, playerState)
|
||||
},
|
||||
getNavNode(nodeId) {
|
||||
return projectNavNode(getRuntimeNavNode(nodeId))
|
||||
},
|
||||
getNavChildren(nodeId, playerState) {
|
||||
return getNavChildren(nodeId, playerState)
|
||||
},
|
||||
ensureCityBundle(cityId) {
|
||||
return bundleResolver.ensureCityBundle(cityId)
|
||||
},
|
||||
validateContent(options) {
|
||||
return validateContent(options)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
continents,
|
||||
cities,
|
||||
createDefaultPlayerState,
|
||||
}
|
||||
|
||||
export default createContentSystem
|
||||
24
js/content/registry/bundle-resolver.js
Normal file
24
js/content/registry/bundle-resolver.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export function createBundleResolver(cityRegistry) {
|
||||
return {
|
||||
resolveCityBundle(cityId) {
|
||||
const city = cityRegistry.getCity(cityId)
|
||||
|
||||
return city ? city.bundle : null
|
||||
},
|
||||
ensureCityBundle(cityId) {
|
||||
const bundle = this.resolveCityBundle(cityId)
|
||||
|
||||
if (!bundle) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
cityId,
|
||||
...bundle,
|
||||
status: 'ready',
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default createBundleResolver
|
||||
30
js/content/registry/city-registry.js
Normal file
30
js/content/registry/city-registry.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export function createCityRegistry(cities) {
|
||||
const cityMap = new Map(
|
||||
[...cities]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((city) => [city.id, city]),
|
||||
)
|
||||
|
||||
return {
|
||||
getAllCities() {
|
||||
return [...cityMap.values()]
|
||||
},
|
||||
getCity(cityId) {
|
||||
return cityMap.get(cityId) ?? null
|
||||
},
|
||||
listCitiesByContinent(continentId) {
|
||||
return [...cityMap.values()].filter((city) => city.continentId === continentId)
|
||||
},
|
||||
getLevelPreset(cityId, levelId) {
|
||||
const city = cityMap.get(cityId)
|
||||
|
||||
if (!city) {
|
||||
return null
|
||||
}
|
||||
|
||||
return city.levelPresets.find((preset) => preset.id === levelId) ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default createCityRegistry
|
||||
21
js/content/registry/continent-registry.js
Normal file
21
js/content/registry/continent-registry.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export function createContinentRegistry(continents) {
|
||||
const continentMap = new Map(
|
||||
[...continents]
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map((continent) => [continent.id, continent]),
|
||||
)
|
||||
|
||||
return {
|
||||
getContinentList() {
|
||||
return [...continentMap.values()]
|
||||
},
|
||||
getContinent(continentId) {
|
||||
return continentMap.get(continentId) ?? null
|
||||
},
|
||||
hasContinent(continentId) {
|
||||
return continentMap.has(continentId)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default createContinentRegistry
|
||||
45
js/content/registry/progress-projector.js
Normal file
45
js/content/registry/progress-projector.js
Normal file
@@ -0,0 +1,45 @@
|
||||
function getLevelEntries(levelProgress = {}) {
|
||||
return Object.values(levelProgress)
|
||||
}
|
||||
|
||||
export function projectCityProgress(city, playerState) {
|
||||
const levelProgress = playerState.levelProgress[city.id] ?? {}
|
||||
const levels = getLevelEntries(levelProgress)
|
||||
const completedLevels = levels.filter((level) => level.completed).length
|
||||
const totalStars = levels.reduce((sum, level) => sum + (level.stars ?? 0), 0)
|
||||
const totalLevels = city.levelPresets.length
|
||||
const isUnlocked = playerState.unlockedCities.includes(city.id)
|
||||
const isCompleted = totalLevels > 0 && completedLevels >= totalLevels
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
completedLevels,
|
||||
totalLevels,
|
||||
totalStars,
|
||||
isUnlocked,
|
||||
isCompleted,
|
||||
isCollected: playerState.collectedCats.includes(city.id),
|
||||
hasPassportStamp: playerState.passportStamps.includes(city.id),
|
||||
}
|
||||
}
|
||||
|
||||
export function projectCityCardView(city, playerState) {
|
||||
const progress = projectCityProgress(city, playerState)
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
name: city.display.name,
|
||||
nameEn: city.display.nameEn,
|
||||
bgColor: city.display.bgColor,
|
||||
catImage: city.cover.catImage,
|
||||
catThumb: city.cover.catThumb,
|
||||
isUnlocked: progress.isUnlocked,
|
||||
isCompleted: progress.isCompleted,
|
||||
isCollected: progress.isCollected,
|
||||
completedLevels: progress.completedLevels,
|
||||
totalLevels: progress.totalLevels,
|
||||
totalStars: progress.totalStars,
|
||||
}
|
||||
}
|
||||
|
||||
export default projectCityCardView
|
||||
24
js/content/validation/validate-assets.js
Normal file
24
js/content/validation/validate-assets.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export function validateAssets(city, assetExists) {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
|
||||
if (typeof assetExists !== 'function') {
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
const assetPaths = [
|
||||
city.cover?.catImage,
|
||||
city.cover?.catThumb,
|
||||
...(city.elements ?? []).map((element) => element.image),
|
||||
].filter(Boolean)
|
||||
|
||||
for (const assetPath of assetPaths) {
|
||||
if (!assetExists(assetPath)) {
|
||||
errors.push(`City "${city.id}" asset is missing: ${assetPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export default validateAssets
|
||||
90
js/content/validation/validate-city.js
Normal file
90
js/content/validation/validate-city.js
Normal file
@@ -0,0 +1,90 @@
|
||||
const CATEGORY_MINIMUMS = {
|
||||
landmark: 2,
|
||||
food: 3,
|
||||
culture: 2,
|
||||
item: 2,
|
||||
nature: 2,
|
||||
}
|
||||
|
||||
function countCategories(elements) {
|
||||
return elements.reduce((counts, element) => {
|
||||
counts[element.category] = (counts[element.category] ?? 0) + 1
|
||||
return counts
|
||||
}, {})
|
||||
}
|
||||
|
||||
function isMultipleOfThree(value) {
|
||||
return Number.isInteger(value) && value > 0 && value % 3 === 0
|
||||
}
|
||||
|
||||
export function validateCity(city, continentIds, cityIds) {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
|
||||
if (!city.id) {
|
||||
errors.push('City is missing id')
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
if (!continentIds.has(city.continentId)) {
|
||||
errors.push(`City "${city.id}" references unknown continent "${city.continentId}"`)
|
||||
}
|
||||
|
||||
if (!city.display?.name || !city.display?.bgColor) {
|
||||
errors.push(`City "${city.id}" is missing display metadata`)
|
||||
}
|
||||
|
||||
if (!city.cover?.catImage || !city.cover?.catThumb) {
|
||||
errors.push(`City "${city.id}" is missing cat cover assets`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(city.elements) || city.elements.length < 12 || city.elements.length > 15) {
|
||||
errors.push(`City "${city.id}" must declare 12-15 elements`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(city.levelPresets) || city.levelPresets.length !== 6) {
|
||||
errors.push(`City "${city.id}" must declare exactly 6 level presets`)
|
||||
}
|
||||
|
||||
if (city.unlockAfterCityId && !cityIds.has(city.unlockAfterCityId)) {
|
||||
errors.push(`City "${city.id}" unlockAfterCityId references unknown city "${city.unlockAfterCityId}"`)
|
||||
}
|
||||
|
||||
const seenElementIds = new Set()
|
||||
const seenElementNames = new Set()
|
||||
const categoryCounts = countCategories(city.elements ?? [])
|
||||
|
||||
for (const element of city.elements ?? []) {
|
||||
if (seenElementIds.has(element.id)) {
|
||||
errors.push(`City "${city.id}" has duplicate element id "${element.id}"`)
|
||||
}
|
||||
seenElementIds.add(element.id)
|
||||
|
||||
if (seenElementNames.has(element.name)) {
|
||||
warnings.push(`City "${city.id}" has duplicate element name "${element.name}"`)
|
||||
}
|
||||
seenElementNames.add(element.name)
|
||||
}
|
||||
|
||||
for (const [category, minimum] of Object.entries(CATEGORY_MINIMUMS)) {
|
||||
if ((categoryCounts[category] ?? 0) < minimum) {
|
||||
errors.push(`City "${city.id}" category "${category}" must contain at least ${minimum} elements`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const preset of city.levelPresets ?? []) {
|
||||
const values = Array.isArray(preset.piecesPerElement) ? preset.piecesPerElement : [preset.piecesPerElement]
|
||||
|
||||
if (Array.isArray(preset.piecesPerElement) && preset.piecesPerElement.length !== preset.elementCount) {
|
||||
errors.push(`City "${city.id}" level ${preset.id} piecesPerElement array length must match elementCount`)
|
||||
}
|
||||
|
||||
if (!values.every(isMultipleOfThree)) {
|
||||
errors.push(`City "${city.id}" level ${preset.id} piecesPerElement must contain only multiples of 3`)
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export default validateCity
|
||||
47
js/content/validation/validate-continent.js
Normal file
47
js/content/validation/validate-continent.js
Normal file
@@ -0,0 +1,47 @@
|
||||
function sameMembers(left, right) {
|
||||
if (left.length !== right.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rightSet = new Set(right)
|
||||
|
||||
return left.every((item) => rightSet.has(item))
|
||||
}
|
||||
|
||||
export function validateContinent(continent, cityIds) {
|
||||
const errors = []
|
||||
const warnings = []
|
||||
|
||||
if (!continent.id) {
|
||||
errors.push('Continent is missing id')
|
||||
}
|
||||
|
||||
const hasRuntimeCityOrder = Array.isArray(continent.cityIds) && continent.cityIds.length > 0
|
||||
const hasUnlockOrder = Array.isArray(continent.unlockOrder) && continent.unlockOrder.length > 0
|
||||
|
||||
if (hasRuntimeCityOrder && hasUnlockOrder && !sameMembers(continent.cityIds, continent.unlockOrder)) {
|
||||
errors.push(`Continent "${continent.id}" cityIds and unlockOrder must contain the same cities`)
|
||||
}
|
||||
|
||||
for (const cityId of continent.cityIds ?? []) {
|
||||
if (!cityIds.has(cityId)) {
|
||||
errors.push(`Continent "${continent.id}" references unknown city "${cityId}"`)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRuntimeCityOrder && !hasUnlockOrder) {
|
||||
errors.push(`Continent "${continent.id}" must declare unlockOrder when cityIds are present`)
|
||||
}
|
||||
|
||||
if (hasUnlockOrder && !hasRuntimeCityOrder) {
|
||||
errors.push(`Continent "${continent.id}" must declare cityIds when unlockOrder is present`)
|
||||
}
|
||||
|
||||
if (continent.bundle && !continent.bundle.packId) {
|
||||
warnings.push(`Continent "${continent.id}" has no bundle packId`)
|
||||
}
|
||||
|
||||
return { errors, warnings }
|
||||
}
|
||||
|
||||
export default validateContinent
|
||||
55
js/gameplay/difficulty/anchors.js
Normal file
55
js/gameplay/difficulty/anchors.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const BOUNDS = {
|
||||
left: 32,
|
||||
right: 358,
|
||||
top: 130,
|
||||
bottom: 560,
|
||||
}
|
||||
|
||||
export function createAnchorGrid(columns = 6, rows = 8) {
|
||||
const anchors = []
|
||||
const width = BOUNDS.right - BOUNDS.left
|
||||
const height = BOUNDS.bottom - BOUNDS.top
|
||||
const stepX = width / (columns - 1)
|
||||
const stepY = height / (rows - 1)
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
anchors.push({
|
||||
id: `anchor_${row}_${column}`,
|
||||
x: BOUNDS.left + column * stepX,
|
||||
y: BOUNDS.top + row * stepY,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return anchors
|
||||
}
|
||||
|
||||
export function getLayerDistribution(density, layers) {
|
||||
const presets = {
|
||||
low: [0.4, 0.35, 0.25],
|
||||
medium: [0.3, 0.3, 0.25, 0.15],
|
||||
medium_high: [0.28, 0.27, 0.25, 0.2],
|
||||
high: [0.25, 0.25, 0.25, 0.25],
|
||||
}
|
||||
const base = presets[density] ?? presets.medium
|
||||
const sliced = base.slice(0, layers)
|
||||
const total = sliced.reduce((sum, value) => sum + value, 0)
|
||||
|
||||
return sliced.map((value) => value / total)
|
||||
}
|
||||
|
||||
export function allocateLayerCounts(totalPieces, density, layers) {
|
||||
const distribution = getLayerDistribution(density, layers)
|
||||
const counts = distribution.map((value) => Math.floor(totalPieces * value))
|
||||
let assigned = counts.reduce((sum, value) => sum + value, 0)
|
||||
|
||||
while (assigned < totalPieces) {
|
||||
for (let index = 0; index < counts.length && assigned < totalPieces; index += 1) {
|
||||
counts[index] += 1
|
||||
assigned += 1
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
47
js/gameplay/difficulty/classify-deadlock.js
Normal file
47
js/gameplay/difficulty/classify-deadlock.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getClickablePieces } from './overlap-graph.js'
|
||||
|
||||
function getPotentialMatches(slot, clickablePieces) {
|
||||
return clickablePieces.filter((piece) => {
|
||||
const sameCount = slot.filter((slottedPiece) => slottedPiece.elementId === piece.elementId).length
|
||||
return sameCount >= 2
|
||||
})
|
||||
}
|
||||
|
||||
export function classifyDeadlock(runtimeState, boardState) {
|
||||
const slot = runtimeState.slot ?? []
|
||||
const bypass = runtimeState.bypass ?? []
|
||||
const clickablePieces = getClickablePieces(boardState)
|
||||
|
||||
if (slot.length < 7) {
|
||||
return {
|
||||
type: 'none',
|
||||
clickablePieces,
|
||||
}
|
||||
}
|
||||
|
||||
const matchablePieces = getPotentialMatches(slot, clickablePieces)
|
||||
|
||||
if (matchablePieces.length > 0) {
|
||||
return {
|
||||
type: 'none',
|
||||
clickablePieces,
|
||||
matchablePieces,
|
||||
}
|
||||
}
|
||||
|
||||
if (bypass.length > 0) {
|
||||
return {
|
||||
type: 'soft',
|
||||
clickablePieces,
|
||||
matchablePieces: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'hard',
|
||||
clickablePieces,
|
||||
matchablePieces: [],
|
||||
}
|
||||
}
|
||||
|
||||
export default classifyDeadlock
|
||||
29
js/gameplay/difficulty/evaluate-board.js
Normal file
29
js/gameplay/difficulty/evaluate-board.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getClickablePieces } from './overlap-graph.js'
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function evaluateBoard(boardState, levelPreset) {
|
||||
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
|
||||
const clickablePieces = getClickablePieces(boardState)
|
||||
const totalPieces = pieces.length
|
||||
const initialClickableRatio = totalPieces === 0 ? 0 : clickablePieces.length / totalPieces
|
||||
const targetPassRate = levelPreset?.targetPassRate ?? 0.75
|
||||
const visibilityBonus = (initialClickableRatio - 0.3) * 0.35
|
||||
const densityPenalty = levelPreset?.density === 'high' ? 0.05 : levelPreset?.density === 'medium_high' ? 0.03 : 0
|
||||
const toolFreePassRate = clamp(targetPassRate + visibilityBonus - densityPenalty, 0.45, 0.99)
|
||||
const toolAssistPassRate = clamp(toolFreePassRate + 0.12, toolFreePassRate, 1)
|
||||
const avgTurnsToFinish = totalPieces / 1.2
|
||||
|
||||
return {
|
||||
totalPieces,
|
||||
initialClickableRatio,
|
||||
simulatedPassRate: toolFreePassRate,
|
||||
avgTurnsToFinish,
|
||||
toolFreePassRate,
|
||||
toolAssistPassRate,
|
||||
}
|
||||
}
|
||||
|
||||
export default evaluateBoard
|
||||
161
js/gameplay/difficulty/generate-board.js
Normal file
161
js/gameplay/difficulty/generate-board.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createAnchorGrid, allocateLayerCounts } from './anchors.js'
|
||||
import { evaluateBoard } from './evaluate-board.js'
|
||||
import { buildOverlapGraph } from './overlap-graph.js'
|
||||
import { createRng, shuffleWithRng } from './random.js'
|
||||
|
||||
function normalizePiecesPerElement(piecesPerElement, elementCount) {
|
||||
if (Array.isArray(piecesPerElement)) {
|
||||
return piecesPerElement.slice(0, elementCount)
|
||||
}
|
||||
|
||||
return Array.from({ length: elementCount }, () => piecesPerElement)
|
||||
}
|
||||
|
||||
function pickElements(city, levelPreset, rng) {
|
||||
const shuffled = shuffleWithRng(city.elements, rng)
|
||||
const categorySeen = new Set()
|
||||
const selected = []
|
||||
|
||||
for (const element of shuffled) {
|
||||
if (!categorySeen.has(element.category)) {
|
||||
selected.push(element)
|
||||
categorySeen.add(element.category)
|
||||
}
|
||||
if (selected.length === levelPreset.elementCount) {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of shuffled) {
|
||||
if (!selected.includes(element)) {
|
||||
selected.push(element)
|
||||
}
|
||||
if (selected.length === levelPreset.elementCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
function createPiecePool(selectedElements, counts) {
|
||||
const pieces = []
|
||||
let sequence = 1
|
||||
|
||||
selectedElements.forEach((element, elementIndex) => {
|
||||
const count = counts[elementIndex]
|
||||
for (let offset = 0; offset < count; offset += 1) {
|
||||
pieces.push({
|
||||
id: `piece_${String(sequence).padStart(4, '0')}`,
|
||||
elementId: element.id,
|
||||
})
|
||||
sequence += 1
|
||||
}
|
||||
})
|
||||
|
||||
return pieces
|
||||
}
|
||||
|
||||
function createLayerAnchors(anchorGrid, rng) {
|
||||
return shuffleWithRng(anchorGrid, rng)
|
||||
}
|
||||
|
||||
function placePieces(piecePool, levelPreset, rng) {
|
||||
const anchors = createAnchorGrid()
|
||||
const layerCounts = allocateLayerCounts(piecePool.length, levelPreset.density, levelPreset.layers)
|
||||
const placedPieces = []
|
||||
let poolIndex = 0
|
||||
|
||||
for (let layer = 0; layer < layerCounts.length; layer += 1) {
|
||||
const layerAnchors = createLayerAnchors(anchors, rng)
|
||||
const previousLayerPieces = placedPieces.filter((piece) => piece.layer === layer - 1)
|
||||
|
||||
for (let index = 0; index < layerCounts[layer]; index += 1) {
|
||||
const source = piecePool[poolIndex]
|
||||
const overlapChance = levelPreset.density === 'high'
|
||||
? 0.85
|
||||
: levelPreset.density === 'medium_high'
|
||||
? 0.7
|
||||
: levelPreset.density === 'medium'
|
||||
? 0.55
|
||||
: 0.35
|
||||
|
||||
let anchor = layerAnchors[index % layerAnchors.length]
|
||||
|
||||
if (layer > 0 && previousLayerPieces.length > 0 && rng() < overlapChance) {
|
||||
const target = previousLayerPieces[Math.floor(rng() * previousLayerPieces.length)]
|
||||
anchor = { x: target.x + 6, y: target.y + 6 }
|
||||
}
|
||||
|
||||
placedPieces.push({
|
||||
...source,
|
||||
layer,
|
||||
x: Math.round(anchor.x + (rng() - 0.5) * 12),
|
||||
y: Math.round(anchor.y + (rng() - 0.5) * 12),
|
||||
width: 64,
|
||||
height: 64,
|
||||
rotation: Math.round((rng() - 0.5) * 12),
|
||||
removed: false,
|
||||
})
|
||||
|
||||
poolIndex += 1
|
||||
}
|
||||
}
|
||||
|
||||
return placedPieces
|
||||
}
|
||||
|
||||
export function generateBoardFromDefinition({
|
||||
boardId,
|
||||
cityId,
|
||||
levelId,
|
||||
seed,
|
||||
elements,
|
||||
levelPreset,
|
||||
extraState = {},
|
||||
}) {
|
||||
const effectiveSeed = seed ?? levelPreset.seedBase
|
||||
const rng = createRng(effectiveSeed)
|
||||
const selectedElements = [...elements]
|
||||
const counts = normalizePiecesPerElement(levelPreset.piecesPerElement, levelPreset.elementCount)
|
||||
const piecePool = createPiecePool(selectedElements, counts)
|
||||
const pieces = placePieces(shuffleWithRng(piecePool, rng), levelPreset, rng)
|
||||
const overlapGraph = buildOverlapGraph(pieces)
|
||||
const boardState = {
|
||||
boardId: boardId ?? `${cityId}-${levelId}-${effectiveSeed}`,
|
||||
cityId,
|
||||
levelId,
|
||||
seed: effectiveSeed,
|
||||
pieces,
|
||||
overlapGraph,
|
||||
metrics: {},
|
||||
...extraState,
|
||||
}
|
||||
|
||||
boardState.metrics = evaluateBoard(boardState, levelPreset)
|
||||
|
||||
return boardState
|
||||
}
|
||||
|
||||
export function generateBoard({ cityId, levelId, seed, contentSystem }) {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
const levelPreset = contentSystem.getLevelPreset(cityId, levelId)
|
||||
|
||||
if (!city || !levelPreset) {
|
||||
throw new Error(`Unknown board target: ${cityId}#${levelId}`)
|
||||
}
|
||||
|
||||
const effectiveSeed = seed ?? levelPreset.seedBase
|
||||
const rng = createRng(effectiveSeed)
|
||||
const selectedElements = pickElements(city, levelPreset, rng)
|
||||
|
||||
return generateBoardFromDefinition({
|
||||
cityId,
|
||||
levelId,
|
||||
seed: effectiveSeed,
|
||||
elements: selectedElements,
|
||||
levelPreset,
|
||||
})
|
||||
}
|
||||
|
||||
export default generateBoard
|
||||
99
js/gameplay/difficulty/generate-mashup-board.js
Normal file
99
js/gameplay/difficulty/generate-mashup-board.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { generateBoardFromDefinition } from './generate-board.js'
|
||||
import { createRng, shuffleWithRng } from './random.js'
|
||||
|
||||
function createMashupLevelPreset(elementCount) {
|
||||
return {
|
||||
id: 1,
|
||||
seedBase: 31001,
|
||||
elementCount,
|
||||
piecesPerElement: Array.from({ length: elementCount }, () => 3),
|
||||
layers: 4,
|
||||
density: 'medium_high',
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceCities(cityIds, contentSystem) {
|
||||
return cityIds
|
||||
.map((cityId) => contentSystem.getCity(cityId))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function createCityElementPools(cities, rng) {
|
||||
return cities.map((city) => ({
|
||||
cityId: city.id,
|
||||
cursor: 0,
|
||||
elements: shuffleWithRng(
|
||||
city.elements.map((element) => ({
|
||||
...element,
|
||||
sourceCityId: city.id,
|
||||
sourceCityName: city.display.name,
|
||||
})),
|
||||
rng,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function pickMashupElements(cities, targetCount, rng) {
|
||||
const pools = createCityElementPools(cities, rng)
|
||||
const selected = []
|
||||
|
||||
while (selected.length < targetCount) {
|
||||
let didAdd = false
|
||||
|
||||
for (const pool of pools) {
|
||||
const element = pool.elements[pool.cursor]
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
||||
selected.push(element)
|
||||
pool.cursor += 1
|
||||
didAdd = true
|
||||
|
||||
if (selected.length === targetCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!didAdd) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
export function generateMashupBoard({ cityIds, seed, contentSystem }) {
|
||||
const cities = getSourceCities(cityIds, contentSystem)
|
||||
|
||||
if (cities.length === 0) {
|
||||
throw new Error('Mashup mode requires at least one source city')
|
||||
}
|
||||
|
||||
const effectiveSeed = seed ?? 31001
|
||||
const rng = createRng(effectiveSeed)
|
||||
const maxElementCount = cities.reduce((sum, city) => sum + city.elements.length, 0)
|
||||
const elementCount = Math.min(8, maxElementCount)
|
||||
const levelPreset = createMashupLevelPreset(elementCount)
|
||||
const selectedElements = pickMashupElements(cities, elementCount, rng)
|
||||
|
||||
return generateBoardFromDefinition({
|
||||
boardId: `mashup-${effectiveSeed}`,
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
seed: effectiveSeed,
|
||||
elements: selectedElements,
|
||||
levelPreset,
|
||||
extraState: {
|
||||
sourceCityIds: cities.map((city) => city.id),
|
||||
elementDefinitions: selectedElements.map((element) => ({
|
||||
elementId: element.id,
|
||||
name: element.name,
|
||||
sourceCityId: element.sourceCityId,
|
||||
sourceCityName: element.sourceCityName,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default generateMashupBoard
|
||||
10
js/gameplay/difficulty/index.js
Normal file
10
js/gameplay/difficulty/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export { classifyDeadlock } from './classify-deadlock.js'
|
||||
export { evaluateBoard } from './evaluate-board.js'
|
||||
export { generateBoard } from './generate-board.js'
|
||||
export { generateMashupBoard } from './generate-mashup-board.js'
|
||||
export {
|
||||
buildOverlapGraph,
|
||||
getClickablePieces,
|
||||
rebuildGraphAfterShuffle,
|
||||
rebuildGraphAfterUndo,
|
||||
} from './overlap-graph.js'
|
||||
74
js/gameplay/difficulty/overlap-graph.js
Normal file
74
js/gameplay/difficulty/overlap-graph.js
Normal file
@@ -0,0 +1,74 @@
|
||||
function getPieceArea(piece) {
|
||||
return piece.width * piece.height
|
||||
}
|
||||
|
||||
function overlapArea(left, right) {
|
||||
const overlapWidth = Math.max(0, Math.min(left.x + left.width, right.x + right.width) - Math.max(left.x, right.x))
|
||||
const overlapHeight = Math.max(0, Math.min(left.y + left.height, right.y + right.height) - Math.max(left.y, right.y))
|
||||
|
||||
return overlapWidth * overlapHeight
|
||||
}
|
||||
|
||||
function overlapsCenterZone(lower, upper) {
|
||||
const centerWidth = lower.width * 0.5
|
||||
const centerHeight = lower.height * 0.5
|
||||
const centerBox = {
|
||||
x: lower.x + lower.width * 0.25,
|
||||
y: lower.y + lower.height * 0.25,
|
||||
width: centerWidth,
|
||||
height: centerHeight,
|
||||
}
|
||||
|
||||
return overlapArea(centerBox, upper) > 0
|
||||
}
|
||||
|
||||
export function buildOverlapGraph(pieces) {
|
||||
const graph = Object.fromEntries(pieces.map((piece) => [piece.id, []]))
|
||||
|
||||
for (let leftIndex = 0; leftIndex < pieces.length; leftIndex += 1) {
|
||||
for (let rightIndex = 0; rightIndex < pieces.length; rightIndex += 1) {
|
||||
if (leftIndex === rightIndex) {
|
||||
continue
|
||||
}
|
||||
|
||||
const blocker = pieces[leftIndex]
|
||||
const blocked = pieces[rightIndex]
|
||||
|
||||
if (blocker.layer <= blocked.layer) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ratio = overlapArea(blocker, blocked) / getPieceArea(blocked)
|
||||
|
||||
if (ratio >= 0.2 && overlapsCenterZone(blocked, blocker)) {
|
||||
graph[blocker.id].push(blocked.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
export function getClickablePieces(boardState) {
|
||||
const pieces = (boardState.pieces ?? []).filter((piece) => !piece.removed)
|
||||
const indegree = Object.fromEntries(pieces.map((piece) => [piece.id, 0]))
|
||||
const graph = boardState.overlapGraph ?? {}
|
||||
|
||||
for (const blockedIds of Object.values(graph)) {
|
||||
for (const blockedId of blockedIds) {
|
||||
if (blockedId in indegree) {
|
||||
indegree[blockedId] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pieces.filter((piece) => indegree[piece.id] === 0)
|
||||
}
|
||||
|
||||
export function rebuildGraphAfterShuffle(boardState) {
|
||||
return buildOverlapGraph(boardState.pieces ?? [])
|
||||
}
|
||||
|
||||
export function rebuildGraphAfterUndo(boardState) {
|
||||
return buildOverlapGraph(boardState.pieces ?? [])
|
||||
}
|
||||
36
js/gameplay/difficulty/random.js
Normal file
36
js/gameplay/difficulty/random.js
Normal file
@@ -0,0 +1,36 @@
|
||||
export function hashSeed(input) {
|
||||
const value = String(input)
|
||||
let hash = 2166136261
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
export function createRng(seed) {
|
||||
let state = typeof seed === 'number' ? seed >>> 0 : hashSeed(seed)
|
||||
|
||||
return function next() {
|
||||
state += 0x6D2B79F5
|
||||
let value = state
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1)
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export function shuffleWithRng(items, rng) {
|
||||
const result = [...items]
|
||||
|
||||
for (let index = result.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(rng() * (index + 1))
|
||||
const temp = result[index]
|
||||
result[index] = result[swapIndex]
|
||||
result[swapIndex] = temp
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
194
js/gameplay/session/index.js
Normal file
194
js/gameplay/session/index.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import { generateBoard, getClickablePieces } from '../difficulty/index.js'
|
||||
|
||||
function cloneBoardState(boardState) {
|
||||
return {
|
||||
...boardState,
|
||||
pieces: boardState.pieces.map((piece) => ({ ...piece })),
|
||||
overlapGraph: Object.fromEntries(
|
||||
Object.entries(boardState.overlapGraph ?? {}).map(([pieceId, blockedIds]) => [pieceId, [...blockedIds]]),
|
||||
),
|
||||
metrics: { ...(boardState.metrics ?? {}) },
|
||||
}
|
||||
}
|
||||
|
||||
function getPieceById(boardState, pieceId) {
|
||||
return boardState.pieces.find((piece) => piece.id === pieceId) ?? null
|
||||
}
|
||||
|
||||
function createSlotEntry(piece) {
|
||||
return {
|
||||
pieceId: piece.id,
|
||||
elementId: piece.elementId,
|
||||
}
|
||||
}
|
||||
|
||||
function removeMatchedEntries(slot, elementId) {
|
||||
let remaining = 3
|
||||
|
||||
return slot.filter((entry) => {
|
||||
if (entry.elementId === elementId && remaining > 0) {
|
||||
remaining -= 1
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function getMatchingTriples(slot) {
|
||||
const counts = slot.reduce((map, entry) => {
|
||||
map.set(entry.elementId, (map.get(entry.elementId) ?? 0) + 1)
|
||||
return map
|
||||
}, new Map())
|
||||
|
||||
const matchedElementId = [...counts.entries()].find(([, count]) => count >= 3)?.[0] ?? null
|
||||
|
||||
return matchedElementId
|
||||
}
|
||||
|
||||
function getStateSnapshot(state, boardState) {
|
||||
return {
|
||||
cityId: state.cityId,
|
||||
levelId: state.levelId,
|
||||
seed: state.seed,
|
||||
status: state.status,
|
||||
slot: [...state.slot],
|
||||
bypass: [...state.bypass],
|
||||
removedPieceIds: [...state.removedPieceIds],
|
||||
boardState,
|
||||
}
|
||||
}
|
||||
|
||||
export function createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
seed,
|
||||
contentSystem,
|
||||
boardState,
|
||||
restartFactory,
|
||||
}) {
|
||||
const resolvedBoard = boardState ? cloneBoardState(boardState) : generateBoard({ cityId, levelId, seed, contentSystem })
|
||||
const state = {
|
||||
cityId,
|
||||
levelId,
|
||||
seed: resolvedBoard.seed,
|
||||
status: 'playing',
|
||||
slot: [],
|
||||
bypass: [],
|
||||
removedPieceIds: [],
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return getStateSnapshot(state, resolvedBoard)
|
||||
}
|
||||
|
||||
function isBoardCleared() {
|
||||
return resolvedBoard.pieces.every((piece) => piece.removed) && state.slot.length === 0
|
||||
}
|
||||
|
||||
function getClickable() {
|
||||
return getClickablePieces(resolvedBoard)
|
||||
}
|
||||
|
||||
function pickPiece(pieceId) {
|
||||
if (state.status !== 'playing') {
|
||||
return {
|
||||
status: state.status,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
const piece = getPieceById(resolvedBoard, pieceId)
|
||||
|
||||
if (!piece || piece.removed) {
|
||||
return {
|
||||
status: 'invalid',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
const clickableIds = new Set(getClickable().map((clickablePiece) => clickablePiece.id))
|
||||
if (!clickableIds.has(pieceId)) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
piece.removed = true
|
||||
state.slot.push(createSlotEntry(piece))
|
||||
|
||||
const matchedElementId = getMatchingTriples(state.slot)
|
||||
if (matchedElementId) {
|
||||
const matchedIds = state.slot
|
||||
.filter((entry) => entry.elementId === matchedElementId)
|
||||
.slice(0, 3)
|
||||
.map((entry) => entry.pieceId)
|
||||
|
||||
state.slot = removeMatchedEntries(state.slot, matchedElementId)
|
||||
state.removedPieceIds.push(...matchedIds)
|
||||
|
||||
if (isBoardCleared()) {
|
||||
state.status = 'won'
|
||||
return {
|
||||
status: 'won',
|
||||
matchedElementId,
|
||||
matchedIds,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'matched',
|
||||
matchedElementId,
|
||||
matchedIds,
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
if (state.slot.length >= 7) {
|
||||
state.status = 'failed'
|
||||
return {
|
||||
status: 'failed',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'picked',
|
||||
state: getState(),
|
||||
}
|
||||
}
|
||||
|
||||
function restart(nextSeed = seed ?? resolvedBoard.seed) {
|
||||
if (restartFactory) {
|
||||
return restartFactory(nextSeed)
|
||||
}
|
||||
|
||||
const freshSession = createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
seed: nextSeed,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
return freshSession
|
||||
}
|
||||
|
||||
return {
|
||||
cityId,
|
||||
levelId,
|
||||
seed: resolvedBoard.seed,
|
||||
getBoardState() {
|
||||
return resolvedBoard
|
||||
},
|
||||
getState,
|
||||
getClickablePieces() {
|
||||
return getClickable()
|
||||
},
|
||||
pickPiece,
|
||||
restart,
|
||||
}
|
||||
}
|
||||
|
||||
export default createGameSession
|
||||
949
js/main.js
949
js/main.js
@@ -1,12 +1,953 @@
|
||||
const { windowWidth, windowHeight } = wx.getSystemInfoSync()
|
||||
import { createContentSystem, createDefaultPlayerState } from './content/index.js'
|
||||
import { createSceneStore } from './ui/scene-store.js'
|
||||
|
||||
const {
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
pixelRatio = 1,
|
||||
} = wx.getSystemInfoSync()
|
||||
|
||||
const canvas = wx.createCanvas()
|
||||
canvas.width = windowWidth * pixelRatio
|
||||
canvas.height = windowHeight * pixelRatio
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.scale(pixelRatio, pixelRatio)
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const STORAGE_KEY = 'player_state'
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = loadPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
const hitTargets = []
|
||||
let transientMessage = ''
|
||||
let transientMessageUntil = 0
|
||||
|
||||
function loadPlayerState() {
|
||||
try {
|
||||
const stored = wx.getStorageSync(STORAGE_KEY)
|
||||
if (!stored) {
|
||||
return createDefaultPlayerState()
|
||||
}
|
||||
|
||||
return {
|
||||
...createDefaultPlayerState(),
|
||||
...stored,
|
||||
inventory: {
|
||||
...createDefaultPlayerState().inventory,
|
||||
...(stored.inventory ?? {}),
|
||||
},
|
||||
settings: {
|
||||
...createDefaultPlayerState().settings,
|
||||
...(stored.settings ?? {}),
|
||||
},
|
||||
stats: {
|
||||
...createDefaultPlayerState().stats,
|
||||
...(stored.stats ?? {}),
|
||||
},
|
||||
cityTeam: {
|
||||
...createDefaultPlayerState().cityTeam,
|
||||
...(stored.cityTeam ?? {}),
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return createDefaultPlayerState()
|
||||
}
|
||||
}
|
||||
|
||||
function savePlayerState() {
|
||||
try {
|
||||
wx.setStorageSync(STORAGE_KEY, playerState)
|
||||
} catch {
|
||||
// Ignore storage write failures in the MVP shell.
|
||||
}
|
||||
}
|
||||
|
||||
function triggerInviteShare() {
|
||||
playerState.stats.totalShareCount += 1
|
||||
savePlayerState()
|
||||
|
||||
if (typeof wx.shareAppMessage === 'function') {
|
||||
try {
|
||||
wx.shareAppMessage({
|
||||
title: '一起来抓猫猫!',
|
||||
query: 'from=invite',
|
||||
})
|
||||
showTransientMessage('已打开分享卡片')
|
||||
return
|
||||
} catch {
|
||||
// Fall through to local feedback.
|
||||
}
|
||||
}
|
||||
|
||||
showTransientMessage('分享入口已触发,真机再校验分享卡片')
|
||||
}
|
||||
|
||||
function drawRoundedRect(x, y, width, height, radius) {
|
||||
const clampedRadius = Math.min(radius, width / 2, height / 2)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + clampedRadius, y)
|
||||
ctx.lineTo(x + width - clampedRadius, y)
|
||||
ctx.arcTo(x + width, y, x + width, y + clampedRadius, clampedRadius)
|
||||
ctx.lineTo(x + width, y + height - clampedRadius)
|
||||
ctx.arcTo(x + width, y + height, x + width - clampedRadius, y + height, clampedRadius)
|
||||
ctx.lineTo(x + clampedRadius, y + height)
|
||||
ctx.arcTo(x, y + height, x, y + height - clampedRadius, clampedRadius)
|
||||
ctx.lineTo(x, y + clampedRadius)
|
||||
ctx.arcTo(x, y, x + clampedRadius, y, clampedRadius)
|
||||
ctx.closePath()
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
const gradient = ctx.createLinearGradient(0, 0, windowWidth, windowHeight)
|
||||
gradient.addColorStop(0, '#fff7ef')
|
||||
gradient.addColorStop(1, '#ffe1d6')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
}
|
||||
|
||||
function registerHitTarget(target) {
|
||||
hitTargets.push(target)
|
||||
}
|
||||
|
||||
function resetHitTargets() {
|
||||
hitTargets.length = 0
|
||||
}
|
||||
|
||||
function showTransientMessage(message, durationMs = 1800) {
|
||||
transientMessage = message
|
||||
transientMessageUntil = Date.now() + durationMs
|
||||
}
|
||||
|
||||
function hashStringToHue(value) {
|
||||
let hash = 0
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) % 360
|
||||
}
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
function getElementStyle(elementId) {
|
||||
const hue = hashStringToHue(elementId)
|
||||
|
||||
return {
|
||||
fill: `hsl(${hue} 82% 87%)`,
|
||||
border: `hsl(${hue} 58% 52%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function hitTest(x, y) {
|
||||
for (let index = hitTargets.length - 1; index >= 0; index -= 1) {
|
||||
const target = hitTargets[index]
|
||||
if (
|
||||
x >= target.x
|
||||
&& x <= target.x + target.width
|
||||
&& y >= target.y
|
||||
&& y <= target.y + target.height
|
||||
) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function drawHeader(title, subtitle) {
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 30px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(title, 24, 36)
|
||||
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '15px sans-serif'
|
||||
ctx.fillText(subtitle, 24, 64)
|
||||
}
|
||||
}
|
||||
|
||||
function drawButton({ x, y, width, height, label, onTap, fillStyle = '#231f2a', textColor = '#ffffff' }) {
|
||||
ctx.fillStyle = fillStyle
|
||||
drawRoundedRect(x, y, width, height, 14)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = textColor
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label, x + width / 2, y + height / 2)
|
||||
|
||||
if (onTap) {
|
||||
registerHitTarget({ x, y, width, height, onTap })
|
||||
}
|
||||
}
|
||||
|
||||
function drawBackButton(onTap) {
|
||||
drawButton({
|
||||
x: 24,
|
||||
y: windowHeight - 58,
|
||||
width: 88,
|
||||
height: 38,
|
||||
label: '返回',
|
||||
onTap,
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
}
|
||||
|
||||
function drawSidebarCard({ x, y, width, height, titleLines, subtitle, onTap, accentColor = '#231f2a' }) {
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = accentColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, width, height, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = accentColor
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
titleLines.forEach((line, index) => {
|
||||
ctx.fillText(line, x + width / 2, y + 34 + index * 18)
|
||||
})
|
||||
|
||||
if (subtitle) {
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText(subtitle, x + width / 2, y + height - 20)
|
||||
}
|
||||
|
||||
if (onTap) {
|
||||
registerHitTarget({ x, y, width, height, onTap })
|
||||
}
|
||||
}
|
||||
|
||||
function drawHomeScene() {
|
||||
const playerStateSnapshot = sceneStore.getPlayerState()
|
||||
drawHeader('城市抓猫猫', '世界主页面 · 3×3 入口')
|
||||
|
||||
const homeTiles = contentSystem.getHomeTiles(playerStateSnapshot)
|
||||
const playerGiftAlbums = contentSystem.getGiftAlbums(playerStateSnapshot)
|
||||
const teamCity = playerStateSnapshot.cityTeam?.teamCityId
|
||||
? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId)
|
||||
: null
|
||||
const totalGiftCount = playerGiftAlbums.reduce((sum, album) => sum + album.collectedCount, 0)
|
||||
const sidebarWidth = Math.max(58, Math.min(70, Math.floor(windowWidth * 0.18)))
|
||||
const sidebarGap = 8
|
||||
const centerX = 24 + sidebarWidth + sidebarGap
|
||||
const centerWidth = windowWidth - 48 - sidebarWidth * 2 - sidebarGap * 2
|
||||
const columns = 3
|
||||
const cardWidth = (centerWidth - 12 * (columns - 1)) / columns
|
||||
const cardHeight = Math.min(118, Math.max(96, Math.floor(windowHeight * 0.15)))
|
||||
const startY = 96
|
||||
|
||||
drawSidebarCard({
|
||||
x: 24,
|
||||
y: startY,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['邀请', '好友'],
|
||||
subtitle: '分享卡片',
|
||||
accentColor: '#FF8A65',
|
||||
onTap() {
|
||||
triggerInviteShare()
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: 24,
|
||||
y: startY + cardHeight + 12,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['开房', 'PK'],
|
||||
subtitle: 'V1.1+',
|
||||
accentColor: '#7E57C2',
|
||||
onTap() {
|
||||
showTransientMessage('开房间 PK 需要实时同步,V1.1+ 接入')
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: windowWidth - 24 - sidebarWidth,
|
||||
y: startY,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['礼物', '区'],
|
||||
subtitle: `${totalGiftCount} 个收藏`,
|
||||
accentColor: '#26A69A',
|
||||
onTap() {
|
||||
sceneStore.openGiftZone()
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
drawSidebarCard({
|
||||
x: windowWidth - 24 - sidebarWidth,
|
||||
y: startY + cardHeight + 12,
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['排行', '榜'],
|
||||
subtitle: teamCity ? `${teamCity.display.name} 战队` : '选择战队',
|
||||
accentColor: '#42A5F5',
|
||||
onTap() {
|
||||
sceneStore.openCityTeamSelect()
|
||||
render()
|
||||
},
|
||||
})
|
||||
|
||||
homeTiles.forEach((tile, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = centerX + column * (cardWidth + 12)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
const isLocked = !tile.isUnlocked && tile.id !== 'coming_soon'
|
||||
|
||||
ctx.fillStyle = tile.isUnlocked ? '#ffffff' : '#f0ebe5'
|
||||
ctx.strokeStyle = tile.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = tile.themeColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(String(index + 1), x + cardWidth / 2, y + 34)
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = tile.name.length >= 5 ? 'bold 13px sans-serif' : 'bold 16px sans-serif'
|
||||
ctx.fillText(tile.name, x + cardWidth / 2, y + 76)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '12px sans-serif'
|
||||
let subtitle = '后续开放'
|
||||
|
||||
if (tile.id === 'asia') {
|
||||
subtitle = 'MVP 已开放'
|
||||
} else if (tile.id === 'mashup') {
|
||||
subtitle = tile.isUnlocked ? '随机混搭' : '通关 2 城解锁'
|
||||
} else if (tile.id === 'coming_soon') {
|
||||
subtitle = '新玩法预告'
|
||||
} else if (!isLocked) {
|
||||
subtitle = '可进入'
|
||||
}
|
||||
|
||||
ctx.fillText(subtitle, x + cardWidth / 2, y + 100)
|
||||
|
||||
if (isLocked) {
|
||||
ctx.fillStyle = '#8d8694'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.fillText('LOCK', x + cardWidth / 2, y + 116)
|
||||
}
|
||||
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
const result = sceneStore.openHomeTile(tile.id)
|
||||
|
||||
if (!result.opened) {
|
||||
if (result.reason === 'locked') {
|
||||
showTransientMessage(tile.id === 'mashup' ? '通关 2 个城市后解锁主题大混战' : `${tile.name} 后续开放`)
|
||||
} else if (result.reason === 'coming-soon') {
|
||||
showTransientMessage('新玩法即将上线,敬请期待!')
|
||||
} else if (result.reason === 'mode-unavailable') {
|
||||
showTransientMessage('主题大混战入口已预留,玩法实现下一步接入')
|
||||
} else {
|
||||
showTransientMessage('该入口暂不可用')
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function drawCitySelectScene(scene) {
|
||||
drawHeader('城市抓猫猫', '亚洲城市页 · 点击已解锁城市')
|
||||
|
||||
const cityCards = contentSystem.listCityCards(scene.continentId, sceneStore.getPlayerState())
|
||||
const columns = 3
|
||||
const cardWidth = (windowWidth - 24 * 2 - 12 * (columns - 1)) / columns
|
||||
const cardHeight = 128
|
||||
const startY = 96
|
||||
|
||||
cityCards.forEach((card, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 12)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
|
||||
ctx.fillStyle = card.isUnlocked ? '#ffffff' : '#ebe5df'
|
||||
ctx.strokeStyle = card.isUnlocked ? card.bgColor : '#d3ccc4'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = card.bgColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(x + cardWidth / 2, y + 34, 22, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 18px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(card.name, x + cardWidth / 2, y + 76)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText(
|
||||
card.isUnlocked ? `${card.completedLevels}/${card.totalLevels} 关` : '未解锁',
|
||||
x + cardWidth / 2,
|
||||
y + 100,
|
||||
)
|
||||
|
||||
if (card.isUnlocked) {
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
sceneStore.openCity(card.cityId)
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawLevelSelectScene(scene) {
|
||||
const city = contentSystem.getCity(scene.cityId)
|
||||
|
||||
drawHeader(city.display.name, `${city.display.tagline} · 选择关卡`)
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 100
|
||||
const startY = 104
|
||||
|
||||
scene.levels.forEach((level, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 14)
|
||||
|
||||
ctx.fillStyle = level.isUnlocked ? '#ffffff' : '#ebe5df'
|
||||
ctx.strokeStyle = level.isCompleted ? city.display.bgColor : '#d7d0c8'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = level.isCompleted ? city.display.bgColor : '#231f2a'
|
||||
ctx.font = 'bold 22px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(`关卡 ${level.levelId}`, x + 18, y + 28)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText(
|
||||
level.isUnlocked ? (level.isCompleted ? `${'★'.repeat(level.stars || 3)}` : '点击开始') : '未解锁',
|
||||
x + 18,
|
||||
y + 64,
|
||||
)
|
||||
|
||||
if (level.isUnlocked) {
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
sceneStore.openLevel(scene.cityId, level.levelId)
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawGiftZoneScene(scene) {
|
||||
const playerStateSnapshot = sceneStore.getPlayerState()
|
||||
const albums = contentSystem.getGiftAlbums(playerStateSnapshot)
|
||||
const entries = contentSystem.getGiftAlbumEntries(scene.selectedTab, playerStateSnapshot)
|
||||
const teamCity = playerStateSnapshot.cityTeam?.teamCityId
|
||||
? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId)
|
||||
: null
|
||||
|
||||
drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票 / 猫猫')
|
||||
|
||||
const summaryX = 24
|
||||
const summaryY = 92
|
||||
const summaryWidth = windowWidth - 48
|
||||
const summaryHeight = 52
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = '#d7d0c8'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(summaryX, summaryY, summaryWidth, summaryHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(
|
||||
albums.map((album) => `${album.name} ${album.collectedCount}/${album.totalCount}`).join(' · '),
|
||||
summaryX + summaryWidth / 2,
|
||||
summaryY + summaryHeight / 2,
|
||||
)
|
||||
|
||||
const teamCardY = 156
|
||||
const teamCardHeight = 58
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = teamCity ? teamCity.display.bgColor : '#8aa3ff'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(summaryX, teamCardY, summaryWidth, teamCardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 15px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(
|
||||
teamCity ? `${teamCity.display.name} 战队` : '还没加入城市战队',
|
||||
summaryX + 16,
|
||||
teamCardY + 22,
|
||||
)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillText(
|
||||
teamCity ? '本地已锁定,排行榜继续占位' : '先选已解锁城市,本地状态先走通',
|
||||
summaryX + 16,
|
||||
teamCardY + 42,
|
||||
)
|
||||
|
||||
drawButton({
|
||||
x: summaryX + summaryWidth - 116,
|
||||
y: teamCardY + 12,
|
||||
width: 100,
|
||||
height: 34,
|
||||
label: teamCity ? '查看战队' : '选择战队',
|
||||
onTap() {
|
||||
sceneStore.openCityTeamSelect()
|
||||
render()
|
||||
},
|
||||
fillStyle: teamCity ? teamCity.display.bgColor : '#231f2a',
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
|
||||
const tabGap = 12
|
||||
const tabWidth = (windowWidth - 24 * 2 - tabGap * (albums.length - 1)) / albums.length
|
||||
const tabsY = 228
|
||||
|
||||
albums.forEach((album, index) => {
|
||||
drawButton({
|
||||
x: 24 + index * (tabWidth + 12),
|
||||
y: tabsY,
|
||||
width: tabWidth,
|
||||
height: 42,
|
||||
label: album.name,
|
||||
fillStyle: scene.selectedTab === album.id ? '#231f2a' : '#ffffff',
|
||||
textColor: scene.selectedTab === album.id ? '#ffffff' : '#231f2a',
|
||||
onTap() {
|
||||
sceneStore.selectGiftTab(album.id)
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 82
|
||||
const startY = 286
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = entry.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = entry.themeColor
|
||||
ctx.font = 'bold 17px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(entry.cityName, x + 16, y + 24)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
if (scene.selectedTab === 'magnets') {
|
||||
ctx.fillText(`冰箱贴 ${entry.collectedCount}/${entry.totalCount}`, x + 16, y + 52)
|
||||
} else if (scene.selectedTab === 'stamps') {
|
||||
ctx.fillText(entry.isCollected ? '邮票已收集' : '邮票未收集', x + 16, y + 52)
|
||||
} else {
|
||||
ctx.fillText(entry.isCollected ? `${entry.catName} 已收集` : '通关城市后收集', x + 16, y + 52)
|
||||
}
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawCityTeamSelectScene(scene) {
|
||||
const isLocked = Boolean(scene.selectedTeamCityId)
|
||||
const selectedCity = isLocked ? contentSystem.getCity(scene.selectedTeamCityId) : null
|
||||
|
||||
drawHeader(
|
||||
'选择城市战队',
|
||||
selectedCity ? `当前所属 ${selectedCity.display.name} 战队 · 本地先锁定` : '从已解锁城市里选择一个战队',
|
||||
)
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 94
|
||||
const startY = 104
|
||||
|
||||
scene.options.forEach((entry, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
const isSelected = scene.selectedTeamCityId === entry.cityId
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = entry.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = entry.themeColor
|
||||
ctx.font = 'bold 18px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(entry.cityName, x + 16, y + 24)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText(`猫猫 ${entry.catName}`, x + 16, y + 48)
|
||||
ctx.fillText(isSelected ? '已加入,当前不可更换' : '可加入本地战队', x + 16, y + 72)
|
||||
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
const selected = sceneStore.chooseCityTeam(entry.cityId)
|
||||
if (selected) {
|
||||
savePlayerState()
|
||||
showTransientMessage(`已加入 ${entry.cityName} 战队`)
|
||||
} else if (isLocked) {
|
||||
showTransientMessage('当前版本先锁定首次选队')
|
||||
} else {
|
||||
showTransientMessage('只能选择已解锁城市')
|
||||
}
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawGameplayScene(scene) {
|
||||
const isMashup = scene.mode === 'mashup'
|
||||
const city = isMashup ? null : contentSystem.getCity(scene.cityId)
|
||||
const session = scene.session
|
||||
const boardState = session.getBoardState()
|
||||
const state = session.getState()
|
||||
const clickableIds = new Set(session.getClickablePieces().map((piece) => piece.id))
|
||||
const activePieces = boardState.pieces
|
||||
.filter((piece) => !piece.removed)
|
||||
.sort((left, right) => left.layer - right.layer)
|
||||
const elementNameMap = isMashup
|
||||
? new Map(scene.elementDefinitions.map((element) => [element.elementId, element.name]))
|
||||
: new Map(city.elements.map((element) => [element.id, element.name]))
|
||||
const title = isMashup ? scene.title : `${city.display.name} · 关卡 ${scene.levelId}`
|
||||
const subtitle = isMashup
|
||||
? `${scene.subtitle ?? '随机混搭'} · ${state.slot.length}/7 槽位`
|
||||
: `${state.slot.length}/7 槽位`
|
||||
|
||||
drawHeader(title, subtitle)
|
||||
|
||||
drawButton({
|
||||
x: windowWidth - 112,
|
||||
y: 22,
|
||||
width: 88,
|
||||
height: 34,
|
||||
label: '重开',
|
||||
onTap() {
|
||||
sceneStore.restartCurrentLevel()
|
||||
render()
|
||||
},
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
|
||||
activePieces.forEach((piece) => {
|
||||
const style = getElementStyle(piece.elementId)
|
||||
const isClickable = clickableIds.has(piece.id)
|
||||
|
||||
ctx.fillStyle = style.fill
|
||||
ctx.strokeStyle = isClickable ? style.border : '#c9c1ba'
|
||||
ctx.lineWidth = isClickable ? 2 : 1
|
||||
ctx.globalAlpha = isClickable ? 1 : 0.5
|
||||
drawRoundedRect(piece.x, piece.y, piece.width, piece.height, 12)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(
|
||||
(elementNameMap.get(piece.elementId) ?? '').slice(0, 2),
|
||||
piece.x + piece.width / 2,
|
||||
piece.y + piece.height / 2,
|
||||
)
|
||||
|
||||
if (isClickable && state.status === 'playing') {
|
||||
registerHitTarget({
|
||||
x: piece.x,
|
||||
y: piece.y,
|
||||
width: piece.width,
|
||||
height: piece.height,
|
||||
onTap() {
|
||||
const result = session.pickPiece(piece.id)
|
||||
|
||||
if (result.status === 'won') {
|
||||
sceneStore.completeGameplayVictory(3)
|
||||
savePlayerState()
|
||||
}
|
||||
|
||||
render()
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const slotY = windowHeight - 126
|
||||
const slotWidth = (windowWidth - 24 * 2 - 6 * 8) / 7
|
||||
|
||||
for (let index = 0; index < 7; index += 1) {
|
||||
const x = 24 + index * (slotWidth + 8)
|
||||
const slotEntry = state.slot[index]
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = '#d5cec7'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, slotY, slotWidth, 54, 12)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
if (slotEntry) {
|
||||
const label = elementNameMap.get(slotEntry.elementId) ?? ''
|
||||
const style = getElementStyle(slotEntry.elementId)
|
||||
ctx.fillStyle = style.border
|
||||
ctx.font = 'bold 12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(label.slice(0, 2), x + slotWidth / 2, slotY + 27)
|
||||
}
|
||||
}
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
|
||||
if (state.status === 'won' || state.status === 'failed') {
|
||||
drawResultOverlay(scene, state.status, isMashup ? scene.accentColor : city.display.bgColor)
|
||||
}
|
||||
}
|
||||
|
||||
function drawResultOverlay(scene, status, accentColor) {
|
||||
const isMashup = scene.mode === 'mashup'
|
||||
const rewardSummary = scene.rewardSummary
|
||||
const title = status === 'won' ? '通关成功' : '挑战失败'
|
||||
let detail = '可以重开当前关卡,继续测试核心循环'
|
||||
|
||||
if (status === 'won' && isMashup) {
|
||||
if (rewardSummary?.type === 'magnet') {
|
||||
const rewardCity = contentSystem.getCity(rewardSummary.cityId)
|
||||
detail = `奖励:${rewardCity?.display.name ?? rewardSummary.cityId} 冰箱贴 ${rewardSummary.levelId}`
|
||||
} else if (rewardSummary?.type === 'inventory') {
|
||||
detail = '奖励:Shuffle +1'
|
||||
} else {
|
||||
detail = '已结算本地混战奖励'
|
||||
}
|
||||
} else if (status === 'won') {
|
||||
detail = '已记录进度,返回关卡页继续'
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(35, 31, 42, 0.45)'
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
|
||||
ctx.fillStyle = '#333333'
|
||||
ctx.font = '24px sans-serif'
|
||||
const boxWidth = windowWidth - 48
|
||||
const boxHeight = 180
|
||||
const x = 24
|
||||
const y = (windowHeight - boxHeight) / 2
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = accentColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, boxWidth, boxHeight, 20)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 28px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('微信小游戏', windowWidth / 2, windowHeight / 2)
|
||||
ctx.fillText(title, x + boxWidth / 2, y + 48)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '15px sans-serif'
|
||||
ctx.fillText(detail, x + boxWidth / 2, y + 86)
|
||||
|
||||
drawButton({
|
||||
x: x + 20,
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? (isMashup ? '返回主页' : '返回关卡') : '返回',
|
||||
onTap() {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
},
|
||||
fillStyle: '#ffffff',
|
||||
textColor: '#231f2a',
|
||||
})
|
||||
|
||||
drawButton({
|
||||
x: x + 30 + (boxWidth - 50) / 2,
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? (isMashup ? '再来一局' : '下一步再做') : '重新挑战',
|
||||
onTap() {
|
||||
if (status === 'won') {
|
||||
if (isMashup) {
|
||||
sceneStore.restartCurrentLevel()
|
||||
} else {
|
||||
sceneStore.goBack()
|
||||
}
|
||||
} else {
|
||||
sceneStore.restartCurrentLevel()
|
||||
}
|
||||
render()
|
||||
},
|
||||
fillStyle: '#231f2a',
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
}
|
||||
|
||||
function drawTransientMessage() {
|
||||
if (!transientMessage || transientMessageUntil <= Date.now()) {
|
||||
transientMessage = ''
|
||||
transientMessageUntil = 0
|
||||
return
|
||||
}
|
||||
|
||||
const width = windowWidth - 48
|
||||
const height = 44
|
||||
const x = 24
|
||||
const y = windowHeight - 116
|
||||
|
||||
ctx.fillStyle = 'rgba(35, 31, 42, 0.88)'
|
||||
drawRoundedRect(x, y, width, height, 14)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(transientMessage, x + width / 2, y + height / 2)
|
||||
}
|
||||
|
||||
function render() {
|
||||
resetHitTargets()
|
||||
drawBackground()
|
||||
|
||||
const scene = sceneStore.getScene()
|
||||
|
||||
if (scene.type === 'home-select') {
|
||||
drawHomeScene()
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'city-select') {
|
||||
drawCitySelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'level-select') {
|
||||
drawLevelSelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'gift-zone') {
|
||||
drawGiftZoneScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'city-team-select') {
|
||||
drawCityTeamSelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'gameplay') {
|
||||
drawGameplayScene(scene)
|
||||
drawTransientMessage()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
const touch = event.touches?.[0]
|
||||
if (!touch) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = hitTest(touch.clientX, touch.clientY)
|
||||
if (target) {
|
||||
target.onTap()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof wx.onTouchStart === 'function') {
|
||||
wx.onTouchStart(handleTouchStart)
|
||||
}
|
||||
|
||||
render()
|
||||
|
||||
483
js/ui/scene-store.js
Normal file
483
js/ui/scene-store.js
Normal file
@@ -0,0 +1,483 @@
|
||||
import { createGameSession } from '../gameplay/session/index.js'
|
||||
import { generateMashupBoard } from '../gameplay/difficulty/index.js'
|
||||
|
||||
function createHomeSelectScene() {
|
||||
return {
|
||||
type: 'home-select',
|
||||
}
|
||||
}
|
||||
|
||||
function createCitySelectScene() {
|
||||
return {
|
||||
type: 'city-select',
|
||||
continentId: 'asia',
|
||||
}
|
||||
}
|
||||
|
||||
function createGiftZoneScene(selectedTab = 'magnets') {
|
||||
return {
|
||||
type: 'gift-zone',
|
||||
selectedTab,
|
||||
}
|
||||
}
|
||||
|
||||
function createCityTeamSelectScene(options, selectedTeamCityId) {
|
||||
return {
|
||||
type: 'city-team-select',
|
||||
options,
|
||||
selectedTeamCityId,
|
||||
}
|
||||
}
|
||||
|
||||
function createGameplayScene({
|
||||
cityId,
|
||||
levelId,
|
||||
session,
|
||||
mode = 'city',
|
||||
title = null,
|
||||
subtitle = null,
|
||||
accentColor = null,
|
||||
sourceCityIds = [],
|
||||
elementDefinitions = [],
|
||||
rewardSummary = null,
|
||||
}) {
|
||||
return {
|
||||
type: 'gameplay',
|
||||
cityId,
|
||||
levelId,
|
||||
session,
|
||||
mode,
|
||||
title,
|
||||
subtitle,
|
||||
accentColor,
|
||||
sourceCityIds,
|
||||
elementDefinitions,
|
||||
rewardSummary,
|
||||
}
|
||||
}
|
||||
|
||||
function createLevelProgress(city, playerState) {
|
||||
const cityProgress = playerState.levelProgress[city.id] ?? {}
|
||||
|
||||
return city.levelPresets.map((preset) => {
|
||||
const levelState = cityProgress[preset.id] ?? null
|
||||
const previousLevel = preset.id - 1
|
||||
const previousCompleted = previousLevel <= 0 || cityProgress[previousLevel]?.completed === true
|
||||
|
||||
return {
|
||||
levelId: preset.id,
|
||||
isUnlocked: previousCompleted,
|
||||
isCompleted: levelState?.completed === true,
|
||||
stars: levelState?.stars ?? 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function ensureCityProgress(playerState, cityId) {
|
||||
if (!playerState.levelProgress[cityId]) {
|
||||
playerState.levelProgress[cityId] = {}
|
||||
}
|
||||
|
||||
return playerState.levelProgress[cityId]
|
||||
}
|
||||
|
||||
function ensureCollection(playerState, key) {
|
||||
if (!Array.isArray(playerState[key])) {
|
||||
playerState[key] = []
|
||||
}
|
||||
|
||||
return playerState[key]
|
||||
}
|
||||
|
||||
function createAcquiredDate(now) {
|
||||
return new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
function createCityTeamOptions(contentSystem, playerState) {
|
||||
return playerState.unlockedCities
|
||||
.map((cityId) => {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
catName: city.cat.name,
|
||||
themeColor: city.display.bgColor,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function createMashupRewardCandidates(contentSystem, playerState) {
|
||||
return playerState.unlockedCities
|
||||
.map((cityId) => contentSystem.getCity(cityId))
|
||||
.filter(Boolean)
|
||||
.flatMap((city) => city.levelPresets.map((preset) => ({
|
||||
magnetId: `magnet_${city.id}_${preset.id}`,
|
||||
cityId: city.id,
|
||||
levelId: preset.id,
|
||||
})))
|
||||
}
|
||||
|
||||
function awardLevelMagnet(playerState, cityId, levelId, now) {
|
||||
const magnets = ensureCollection(playerState, 'collectedMagnets')
|
||||
const magnetId = `magnet_${cityId}_${levelId}`
|
||||
|
||||
if (magnets.some((entry) => entry.magnetId === magnetId)) {
|
||||
return
|
||||
}
|
||||
|
||||
magnets.push({
|
||||
magnetId,
|
||||
cityId,
|
||||
levelId,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
}
|
||||
|
||||
function awardMashupReward(contentSystem, playerState, seed, now) {
|
||||
const rewardCandidates = createMashupRewardCandidates(contentSystem, playerState)
|
||||
const magnets = ensureCollection(playerState, 'collectedMagnets')
|
||||
|
||||
if (rewardCandidates.length === 0) {
|
||||
playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1
|
||||
return {
|
||||
type: 'inventory',
|
||||
itemId: 'shuffle',
|
||||
amount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const reward = rewardCandidates[seed % rewardCandidates.length]
|
||||
|
||||
if (magnets.some((entry) => entry.magnetId === reward.magnetId)) {
|
||||
playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1
|
||||
return {
|
||||
type: 'inventory',
|
||||
itemId: 'shuffle',
|
||||
amount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
magnets.push({
|
||||
...reward,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'magnet',
|
||||
...reward,
|
||||
}
|
||||
}
|
||||
|
||||
function markCityCompletion(contentSystem, playerState, cityId, now) {
|
||||
if (!playerState.collectedCats.includes(cityId)) {
|
||||
playerState.collectedCats.push(cityId)
|
||||
}
|
||||
|
||||
if (!playerState.passportStamps.includes(cityId)) {
|
||||
playerState.passportStamps.push(cityId)
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
const stamps = ensureCollection(playerState, 'collectedStamps')
|
||||
const stampId = city?.passport?.stampId ?? `stamp_${cityId}`
|
||||
|
||||
if (!stamps.some((entry) => entry.stampId === stampId)) {
|
||||
stamps.push({
|
||||
stampId,
|
||||
cityId,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
}
|
||||
|
||||
const nextCityId = city?.unlockAfterCityId
|
||||
|
||||
if (nextCityId && !playerState.unlockedCities.includes(nextCityId)) {
|
||||
playerState.unlockedCities.push(nextCityId)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSceneStore({ contentSystem, playerState, now = () => Date.now() }) {
|
||||
const history = []
|
||||
let currentScene = createHomeSelectScene()
|
||||
|
||||
function createMashupSession(seed = now()) {
|
||||
const boardState = generateMashupBoard({
|
||||
cityIds: playerState.unlockedCities,
|
||||
seed,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
return createGameSession({
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
seed,
|
||||
contentSystem,
|
||||
boardState,
|
||||
restartFactory: (nextSeed) => createMashupSession(nextSeed),
|
||||
})
|
||||
}
|
||||
|
||||
function createMashupGameplayScene(seed = now()) {
|
||||
const session = createMashupSession(seed)
|
||||
const boardState = session.getBoardState()
|
||||
|
||||
return createGameplayScene({
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
session,
|
||||
mode: 'mashup',
|
||||
title: '主题大混战',
|
||||
subtitle: `${boardState.sourceCityIds.length} 城混搭`,
|
||||
accentColor: '#E74C3C',
|
||||
sourceCityIds: boardState.sourceCityIds ?? [],
|
||||
elementDefinitions: boardState.elementDefinitions ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
function getScene() {
|
||||
return currentScene
|
||||
}
|
||||
|
||||
function openHomeTile(tileId) {
|
||||
const tile = contentSystem.getHomeTile(tileId, playerState)
|
||||
if (!tile) {
|
||||
return { opened: false, reason: 'missing' }
|
||||
}
|
||||
|
||||
if (tile.id === 'coming_soon') {
|
||||
return { opened: false, reason: 'coming-soon' }
|
||||
}
|
||||
|
||||
if (!tile.isUnlocked) {
|
||||
return { opened: false, reason: 'locked' }
|
||||
}
|
||||
|
||||
if (tile.id === 'asia') {
|
||||
history.push(currentScene)
|
||||
currentScene = createCitySelectScene()
|
||||
return { opened: true }
|
||||
}
|
||||
|
||||
if (tile.id === 'mashup') {
|
||||
history.push(currentScene)
|
||||
currentScene = createMashupGameplayScene()
|
||||
return { opened: true }
|
||||
}
|
||||
|
||||
return { opened: false, reason: 'unavailable' }
|
||||
}
|
||||
|
||||
function openCity(cityId) {
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
history.push(currentScene)
|
||||
currentScene = {
|
||||
type: 'level-select',
|
||||
cityId,
|
||||
levels: createLevelProgress(city, playerState),
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function openGiftZone(initialTab = 'magnets') {
|
||||
history.push(currentScene)
|
||||
currentScene = createGiftZoneScene(initialTab)
|
||||
return true
|
||||
}
|
||||
|
||||
function selectGiftTab(tabId) {
|
||||
if (currentScene.type !== 'gift-zone') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!['magnets', 'stamps', 'cats'].includes(tabId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
selectedTab: tabId,
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function openCityTeamSelect() {
|
||||
history.push(currentScene)
|
||||
currentScene = createCityTeamSelectScene(
|
||||
createCityTeamOptions(contentSystem, playerState),
|
||||
playerState.cityTeam?.teamCityId ?? null,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function chooseCityTeam(cityId) {
|
||||
if (currentScene.type !== 'city-team-select') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (playerState.cityTeam?.teamCityId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
playerState.cityTeam = {
|
||||
...playerState.cityTeam,
|
||||
teamCityId: cityId,
|
||||
joinedDate: createAcquiredDate(now),
|
||||
lastSwitchDate: null,
|
||||
}
|
||||
|
||||
currentScene = history.pop() ?? createHomeSelectScene()
|
||||
return true
|
||||
}
|
||||
|
||||
function openLevel(cityId, levelId) {
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
const levelCards = createLevelProgress(city, playerState)
|
||||
const targetLevel = levelCards.find((level) => level.levelId === levelId)
|
||||
|
||||
if (!targetLevel || !targetLevel.isUnlocked) {
|
||||
return false
|
||||
}
|
||||
|
||||
history.push(currentScene)
|
||||
currentScene = createGameplayScene({
|
||||
cityId,
|
||||
levelId,
|
||||
session: createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
contentSystem,
|
||||
}),
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function completeLevel({ cityId, levelId, stars = 3 }) {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cityProgress = ensureCityProgress(playerState, cityId)
|
||||
awardLevelMagnet(playerState, cityId, levelId, now)
|
||||
cityProgress[levelId] = {
|
||||
...(cityProgress[levelId] ?? {}),
|
||||
completed: true,
|
||||
stars: Math.max(cityProgress[levelId]?.stars ?? 0, stars),
|
||||
}
|
||||
|
||||
const allLevelsCompleted = city.levelPresets.every((preset) => cityProgress[preset.id]?.completed === true)
|
||||
if (allLevelsCompleted) {
|
||||
markCityCompletion(contentSystem, playerState, cityId, now)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function restartCurrentLevel() {
|
||||
if (currentScene.type !== 'gameplay') {
|
||||
return false
|
||||
}
|
||||
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
session: currentScene.session.restart(),
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function completeGameplayVictory(stars = 3) {
|
||||
if (currentScene.type !== 'gameplay') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentScene.mode === 'mashup') {
|
||||
const reward = awardMashupReward(contentSystem, playerState, currentScene.session.seed, now)
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
rewardSummary: reward,
|
||||
}
|
||||
return reward
|
||||
}
|
||||
|
||||
completeLevel({
|
||||
cityId: currentScene.cityId,
|
||||
levelId: currentScene.levelId,
|
||||
stars,
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'city',
|
||||
cityId: currentScene.cityId,
|
||||
levelId: currentScene.levelId,
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (history.length === 0) {
|
||||
currentScene = createHomeSelectScene()
|
||||
return currentScene
|
||||
}
|
||||
|
||||
currentScene = history.pop()
|
||||
|
||||
if (currentScene.type === 'level-select') {
|
||||
const city = contentSystem.getCity(currentScene.cityId)
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
levels: createLevelProgress(city, playerState),
|
||||
}
|
||||
}
|
||||
|
||||
return currentScene
|
||||
}
|
||||
|
||||
return {
|
||||
getScene,
|
||||
getPlayerState() {
|
||||
return playerState
|
||||
},
|
||||
openHomeTile,
|
||||
openCity,
|
||||
openGiftZone,
|
||||
selectGiftTab,
|
||||
openCityTeamSelect,
|
||||
chooseCityTeam,
|
||||
openLevel,
|
||||
completeLevel,
|
||||
completeGameplayVictory,
|
||||
restartCurrentLevel,
|
||||
goBack,
|
||||
}
|
||||
}
|
||||
|
||||
export default createSceneStore
|
||||
8
package.json
Normal file
8
package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "wechat-minigame",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
152
tests/content-system.test.js
Normal file
152
tests/content-system.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
createContentSystem,
|
||||
createDefaultPlayerState,
|
||||
validateContent,
|
||||
} from '../js/content/index.js'
|
||||
|
||||
test('built-in content validates without errors', () => {
|
||||
const result = validateContent()
|
||||
|
||||
assert.equal(result.errors.length, 0)
|
||||
})
|
||||
|
||||
test('content system projects city cards from player state', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
|
||||
const beijingCard = contentSystem.getCityCardView('beijing', playerState)
|
||||
const tokyoCardBefore = contentSystem.getCityCardView('tokyo', playerState)
|
||||
|
||||
assert.equal(beijingCard.isUnlocked, true)
|
||||
assert.equal(beijingCard.isCollected, false)
|
||||
assert.equal(tokyoCardBefore.isUnlocked, false)
|
||||
|
||||
playerState.levelProgress.beijing = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
playerState.unlockedCities.push('tokyo')
|
||||
playerState.collectedCats.push('beijing')
|
||||
playerState.passportStamps.push('beijing')
|
||||
|
||||
const beijingAfter = contentSystem.getCityCardView('beijing', playerState)
|
||||
const tokyoCardAfter = contentSystem.getCityCardView('tokyo', playerState)
|
||||
|
||||
assert.equal(beijingAfter.isCompleted, true)
|
||||
assert.equal(beijingAfter.isCollected, true)
|
||||
assert.equal(tokyoCardAfter.isUnlocked, true)
|
||||
})
|
||||
|
||||
test('content system exposes runtime navigation roots and city children for the MVP map', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
|
||||
const roots = contentSystem.getRootNavNodes()
|
||||
const asia = contentSystem.getNavNode('asia')
|
||||
const children = contentSystem.getNavChildren('asia', playerState)
|
||||
|
||||
assert.deepEqual(roots.map((node) => node.id), ['asia', 'mashup', 'coming_soon'])
|
||||
assert.equal(asia.childType, 'city')
|
||||
assert.deepEqual(
|
||||
children.map((entry) => entry.id),
|
||||
['beijing', 'tokyo', 'bangkok', 'seoul', 'singapore', 'istanbul'],
|
||||
)
|
||||
assert.equal(children[0].type, 'city')
|
||||
assert.equal(children[0].parentId, 'asia')
|
||||
assert.equal(children[0].isUnlocked, true)
|
||||
assert.equal(children[1].isUnlocked, false)
|
||||
})
|
||||
|
||||
test('content system exposes nine home tiles and gates mashup behind two completed cities', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
|
||||
const initialTiles = contentSystem.getHomeTiles(playerState)
|
||||
|
||||
assert.deepEqual(
|
||||
initialTiles.map((tile) => tile.id),
|
||||
['asia', 'europe', 'north_america', 'south_america', 'africa', 'oceania', 'antarctica', 'mashup', 'coming_soon'],
|
||||
)
|
||||
assert.equal(initialTiles[0].isUnlocked, true)
|
||||
assert.equal(initialTiles[1].isUnlocked, false)
|
||||
assert.equal(initialTiles[7].isUnlocked, false)
|
||||
assert.equal(initialTiles[8].isInteractive, true)
|
||||
|
||||
playerState.levelProgress.beijing = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
playerState.levelProgress.tokyo = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
|
||||
const unlockedTiles = contentSystem.getHomeTiles(playerState)
|
||||
const mashupTile = unlockedTiles.find((tile) => tile.id === 'mashup')
|
||||
|
||||
assert.equal(mashupTile.isUnlocked, true)
|
||||
})
|
||||
|
||||
test('default player state includes city team and empty collection albums', () => {
|
||||
const playerState = createDefaultPlayerState()
|
||||
|
||||
assert.deepEqual(playerState.cityTeam, {
|
||||
teamCityId: null,
|
||||
joinedDate: null,
|
||||
lastSwitchDate: null,
|
||||
})
|
||||
assert.deepEqual(playerState.collectedMagnets, [])
|
||||
assert.deepEqual(playerState.collectedStamps, [])
|
||||
})
|
||||
|
||||
test('content system summarizes MVP gift albums and per-city progress', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
|
||||
playerState.collectedMagnets.push({
|
||||
magnetId: 'magnet_beijing_1',
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
acquiredDate: '2026-03-29T00:00:00.000Z',
|
||||
})
|
||||
playerState.collectedStamps.push({
|
||||
stampId: 'stamp_beijing',
|
||||
cityId: 'beijing',
|
||||
acquiredDate: '2026-03-29T00:00:00.000Z',
|
||||
})
|
||||
playerState.collectedCats.push('beijing')
|
||||
|
||||
const albums = contentSystem.getGiftAlbums(playerState)
|
||||
const magnetEntries = contentSystem.getGiftAlbumEntries('magnets', playerState)
|
||||
const stampEntries = contentSystem.getGiftAlbumEntries('stamps', playerState)
|
||||
const catEntries = contentSystem.getGiftAlbumEntries('cats', playerState)
|
||||
|
||||
assert.deepEqual(albums.map((album) => album.id), ['magnets', 'stamps', 'cats'])
|
||||
assert.deepEqual(albums.map((album) => album.collectedCount), [1, 1, 1])
|
||||
assert.deepEqual(albums.map((album) => album.totalCount), [36, 6, 6])
|
||||
assert.equal(magnetEntries[0].cityId, 'beijing')
|
||||
assert.equal(magnetEntries[0].collectedCount, 1)
|
||||
assert.equal(magnetEntries[0].totalCount, 6)
|
||||
assert.equal(stampEntries[0].cityId, 'beijing')
|
||||
assert.equal(stampEntries[0].isCollected, true)
|
||||
assert.equal(stampEntries[1].isCollected, false)
|
||||
assert.equal(catEntries[0].cityId, 'beijing')
|
||||
assert.equal(catEntries[0].catName, '京京')
|
||||
assert.equal(catEntries[0].isCollected, true)
|
||||
assert.equal(catEntries[1].isCollected, false)
|
||||
})
|
||||
95
tests/difficulty-generator.test.js
Normal file
95
tests/difficulty-generator.test.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { createContentSystem } from '../js/content/index.js'
|
||||
import {
|
||||
classifyDeadlock,
|
||||
evaluateBoard,
|
||||
generateBoard,
|
||||
generateMashupBoard,
|
||||
} from '../js/gameplay/difficulty/index.js'
|
||||
|
||||
test('generateBoard is deterministic for the same city, level, and seed', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
|
||||
const first = generateBoard({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem,
|
||||
})
|
||||
const second = generateBoard({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
assert.deepEqual(first.pieces, second.pieces)
|
||||
assert.deepEqual(first.overlapGraph, second.overlapGraph)
|
||||
})
|
||||
|
||||
test('evaluateBoard exposes baseline metrics for intro level boards', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const board = generateBoard({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem,
|
||||
})
|
||||
const levelPreset = contentSystem.getLevelPreset('beijing', 1)
|
||||
const metrics = evaluateBoard(board, levelPreset)
|
||||
|
||||
assert.equal(metrics.totalPieces, 18)
|
||||
assert.ok(metrics.initialClickableRatio >= 0.3)
|
||||
})
|
||||
|
||||
test('classifyDeadlock identifies a hard deadlock when slot is full and no match path exists', () => {
|
||||
const runtimeState = {
|
||||
slot: [
|
||||
{ elementId: 'a' },
|
||||
{ elementId: 'b' },
|
||||
{ elementId: 'c' },
|
||||
{ elementId: 'd' },
|
||||
{ elementId: 'e' },
|
||||
{ elementId: 'f' },
|
||||
{ elementId: 'g' },
|
||||
],
|
||||
bypass: [],
|
||||
}
|
||||
|
||||
const boardState = {
|
||||
pieces: [
|
||||
{ id: 'p1', elementId: 'x', removed: false },
|
||||
{ id: 'p2', elementId: 'y', removed: false },
|
||||
],
|
||||
overlapGraph: {
|
||||
p1: [],
|
||||
p2: [],
|
||||
},
|
||||
}
|
||||
|
||||
const result = classifyDeadlock(runtimeState, boardState)
|
||||
|
||||
assert.equal(result.type, 'hard')
|
||||
})
|
||||
|
||||
test('generateMashupBoard is deterministic and mixes unlocked city content', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
|
||||
const first = generateMashupBoard({
|
||||
cityIds: ['beijing', 'tokyo'],
|
||||
seed: 33001,
|
||||
contentSystem,
|
||||
})
|
||||
const second = generateMashupBoard({
|
||||
cityIds: ['beijing', 'tokyo'],
|
||||
seed: 33001,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
assert.deepEqual(first.pieces, second.pieces)
|
||||
assert.deepEqual(first.overlapGraph, second.overlapGraph)
|
||||
assert.deepEqual(first.sourceCityIds, ['beijing', 'tokyo'])
|
||||
assert.ok(new Set(first.elementDefinitions.map((entry) => entry.sourceCityId)).size >= 2)
|
||||
})
|
||||
140
tests/game-session.test.js
Normal file
140
tests/game-session.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { createContentSystem } from '../js/content/index.js'
|
||||
import { createGameSession } from '../js/gameplay/session/index.js'
|
||||
|
||||
test('pickPiece adds a clickable piece to the slot', () => {
|
||||
const session = createGameSession({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem: createContentSystem(),
|
||||
})
|
||||
const clickablePiece = session.getClickablePieces()[0]
|
||||
|
||||
const result = session.pickPiece(clickablePiece.id)
|
||||
|
||||
assert.equal(result.status, 'picked')
|
||||
assert.equal(session.getState().slot.length, 1)
|
||||
assert.equal(session.getState().slot[0].pieceId, clickablePiece.id)
|
||||
})
|
||||
|
||||
test('pickPiece auto clears triples from the slot', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const session = createGameSession({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem,
|
||||
boardState: {
|
||||
boardId: 'test',
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 1,
|
||||
pieces: [
|
||||
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p4', elementId: 'beijing_02', layer: 0, x: 0, y: 70, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p5', elementId: 'beijing_02', layer: 0, x: 70, y: 70, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p6', elementId: 'beijing_02', layer: 0, x: 140, y: 70, width: 64, height: 64, rotation: 0, removed: false },
|
||||
],
|
||||
overlapGraph: {
|
||||
p1: [],
|
||||
p2: [],
|
||||
p3: [],
|
||||
p4: [],
|
||||
p5: [],
|
||||
p6: [],
|
||||
},
|
||||
metrics: {},
|
||||
},
|
||||
})
|
||||
|
||||
session.pickPiece('p1')
|
||||
session.pickPiece('p2')
|
||||
const third = session.pickPiece('p3')
|
||||
|
||||
assert.equal(third.status, 'matched')
|
||||
assert.equal(session.getState().slot.length, 0)
|
||||
assert.equal(session.getState().removedPieceIds.length, 3)
|
||||
})
|
||||
|
||||
test('session fails when slot fills without a match', () => {
|
||||
const session = createGameSession({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem: createContentSystem(),
|
||||
boardState: {
|
||||
boardId: 'full-slot',
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 2,
|
||||
pieces: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((key, index) => ({
|
||||
id: `p${index + 1}`,
|
||||
elementId: key,
|
||||
layer: 0,
|
||||
x: index * 70,
|
||||
y: 0,
|
||||
width: 64,
|
||||
height: 64,
|
||||
rotation: 0,
|
||||
removed: false,
|
||||
})),
|
||||
overlapGraph: {
|
||||
p1: [],
|
||||
p2: [],
|
||||
p3: [],
|
||||
p4: [],
|
||||
p5: [],
|
||||
p6: [],
|
||||
p7: [],
|
||||
},
|
||||
metrics: {},
|
||||
},
|
||||
})
|
||||
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
session.pickPiece(`p${index}`)
|
||||
}
|
||||
|
||||
const last = session.pickPiece('p7')
|
||||
|
||||
assert.equal(last.status, 'failed')
|
||||
assert.equal(session.getState().status, 'failed')
|
||||
})
|
||||
|
||||
test('session wins when the final triple clears the board', () => {
|
||||
const session = createGameSession({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 11001,
|
||||
contentSystem: createContentSystem(),
|
||||
boardState: {
|
||||
boardId: 'win-state',
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
seed: 3,
|
||||
pieces: [
|
||||
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
|
||||
],
|
||||
overlapGraph: {
|
||||
p1: [],
|
||||
p2: [],
|
||||
p3: [],
|
||||
},
|
||||
metrics: {},
|
||||
},
|
||||
})
|
||||
|
||||
session.pickPiece('p1')
|
||||
session.pickPiece('p2')
|
||||
const third = session.pickPiece('p3')
|
||||
|
||||
assert.equal(third.status, 'won')
|
||||
assert.equal(session.getState().status, 'won')
|
||||
})
|
||||
242
tests/scene-store.test.js
Normal file
242
tests/scene-store.test.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { createContentSystem, createDefaultPlayerState } from '../js/content/index.js'
|
||||
import { createSceneStore } from '../js/ui/scene-store.js'
|
||||
|
||||
test('scene store starts on the home selection page', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
})
|
||||
|
||||
test('scene store opens level selection for unlocked city and returns back', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
|
||||
const openedHome = sceneStore.openHomeTile('asia')
|
||||
const opened = sceneStore.openCity('beijing')
|
||||
|
||||
assert.equal(openedHome.opened, true)
|
||||
assert.equal(opened, true)
|
||||
assert.equal(sceneStore.getScene().type, 'level-select')
|
||||
assert.equal(sceneStore.getScene().cityId, 'beijing')
|
||||
|
||||
sceneStore.goBack()
|
||||
|
||||
assert.equal(sceneStore.getScene().type, 'city-select')
|
||||
})
|
||||
|
||||
test('scene store opens gameplay scene from a valid level', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
|
||||
sceneStore.openHomeTile('asia')
|
||||
sceneStore.openCity('beijing')
|
||||
const opened = sceneStore.openLevel('beijing', 1)
|
||||
|
||||
assert.equal(opened, true)
|
||||
assert.equal(sceneStore.getScene().type, 'gameplay')
|
||||
assert.equal(sceneStore.getScene().session.cityId, 'beijing')
|
||||
assert.equal(sceneStore.getScene().session.levelId, 1)
|
||||
})
|
||||
|
||||
test('scene store awards magnets, stamps, and unlocks the next city on completion', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => new Date('2026-03-28T12:00:00.000Z').getTime(),
|
||||
})
|
||||
|
||||
sceneStore.completeLevel({
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
stars: 3,
|
||||
})
|
||||
|
||||
assert.deepEqual(playerState.collectedMagnets, [
|
||||
{
|
||||
magnetId: 'magnet_beijing_1',
|
||||
cityId: 'beijing',
|
||||
levelId: 1,
|
||||
acquiredDate: '2026-03-28T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
|
||||
for (let levelId = 2; levelId <= 6; levelId += 1) {
|
||||
sceneStore.completeLevel({
|
||||
cityId: 'beijing',
|
||||
levelId,
|
||||
stars: 3,
|
||||
})
|
||||
}
|
||||
|
||||
assert.equal(playerState.collectedCats.includes('beijing'), true)
|
||||
assert.equal(playerState.passportStamps.includes('beijing'), true)
|
||||
assert.equal(playerState.unlockedCities.includes('tokyo'), true)
|
||||
assert.deepEqual(playerState.collectedStamps, [
|
||||
{
|
||||
stampId: 'stamp_beijing',
|
||||
cityId: 'beijing',
|
||||
acquiredDate: '2026-03-28T12:00:00.000Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('scene store reports locked and placeholder home tiles without leaving the home scene', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
|
||||
const locked = sceneStore.openHomeTile('europe')
|
||||
const soon = sceneStore.openHomeTile('coming_soon')
|
||||
|
||||
assert.deepEqual(locked, { opened: false, reason: 'locked' })
|
||||
assert.deepEqual(soon, { opened: false, reason: 'coming-soon' })
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
})
|
||||
|
||||
test('scene store opens the gift zone and switches between MVP tabs', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({ contentSystem, playerState })
|
||||
|
||||
const opened = sceneStore.openGiftZone()
|
||||
|
||||
assert.equal(opened, true)
|
||||
assert.equal(sceneStore.getScene().type, 'gift-zone')
|
||||
assert.equal(sceneStore.getScene().selectedTab, 'magnets')
|
||||
|
||||
const switched = sceneStore.selectGiftTab('stamps')
|
||||
|
||||
assert.equal(switched, true)
|
||||
assert.equal(sceneStore.getScene().selectedTab, 'stamps')
|
||||
|
||||
const switchedToCats = sceneStore.selectGiftTab('cats')
|
||||
|
||||
assert.equal(switchedToCats, true)
|
||||
assert.equal(sceneStore.getScene().selectedTab, 'cats')
|
||||
|
||||
sceneStore.goBack()
|
||||
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
})
|
||||
|
||||
test('scene store opens city team selection and locks the first chosen team', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => new Date('2026-03-29T08:00:00.000Z').getTime(),
|
||||
})
|
||||
|
||||
const opened = sceneStore.openCityTeamSelect()
|
||||
|
||||
assert.equal(opened, true)
|
||||
assert.equal(sceneStore.getScene().type, 'city-team-select')
|
||||
assert.deepEqual(sceneStore.getScene().options.map((entry) => entry.cityId), ['beijing'])
|
||||
|
||||
const selected = sceneStore.chooseCityTeam('beijing')
|
||||
|
||||
assert.equal(selected, true)
|
||||
assert.deepEqual(playerState.cityTeam, {
|
||||
teamCityId: 'beijing',
|
||||
joinedDate: '2026-03-29T08:00:00.000Z',
|
||||
lastSwitchDate: null,
|
||||
})
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
|
||||
sceneStore.openCityTeamSelect()
|
||||
const reselected = sceneStore.chooseCityTeam('beijing')
|
||||
|
||||
assert.equal(reselected, false)
|
||||
assert.equal(playerState.cityTeam.teamCityId, 'beijing')
|
||||
})
|
||||
|
||||
test('scene store opens mashup gameplay after two completed cities', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => 33001,
|
||||
})
|
||||
|
||||
playerState.unlockedCities.push('tokyo')
|
||||
playerState.levelProgress.beijing = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
playerState.levelProgress.tokyo = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
|
||||
const opened = sceneStore.openHomeTile('mashup')
|
||||
|
||||
assert.deepEqual(opened, { opened: true })
|
||||
assert.equal(sceneStore.getScene().type, 'gameplay')
|
||||
assert.equal(sceneStore.getScene().mode, 'mashup')
|
||||
assert.deepEqual(sceneStore.getScene().sourceCityIds, ['beijing', 'tokyo'])
|
||||
})
|
||||
|
||||
test('scene store awards a deterministic local mashup reward', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => 33001,
|
||||
})
|
||||
|
||||
playerState.unlockedCities.push('tokyo')
|
||||
playerState.levelProgress.beijing = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
playerState.levelProgress.tokyo = {
|
||||
1: { completed: true, stars: 3 },
|
||||
2: { completed: true, stars: 3 },
|
||||
3: { completed: true, stars: 3 },
|
||||
4: { completed: true, stars: 3 },
|
||||
5: { completed: true, stars: 3 },
|
||||
6: { completed: true, stars: 3 },
|
||||
}
|
||||
|
||||
sceneStore.openHomeTile('mashup')
|
||||
const reward = sceneStore.completeGameplayVictory()
|
||||
|
||||
assert.deepEqual(reward, {
|
||||
type: 'magnet',
|
||||
magnetId: 'magnet_beijing_2',
|
||||
cityId: 'beijing',
|
||||
levelId: 2,
|
||||
})
|
||||
assert.deepEqual(playerState.collectedMagnets, [
|
||||
{
|
||||
magnetId: 'magnet_beijing_2',
|
||||
cityId: 'beijing',
|
||||
levelId: 2,
|
||||
acquiredDate: '1970-01-01T00:00:33.001Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user