天赋系统

This commit is contained in:
李鹏宇
2026-08-27 20:15:18 +08:00
parent 5d79209ccb
commit 78aaace049
18 changed files with 1211 additions and 62 deletions
+1
View File
@@ -11,6 +11,7 @@
'data/codex_creature.xml', 'data/codex_creature.xml',
'data/codex_monster.xml', 'data/codex_monster.xml',
'data/codex_race.xml', 'data/codex_race.xml',
'data/codex_talent.xml',
'data/yt_world_build_item.xml', 'data/yt_world_build_item.xml',
'data/yt_world_task.xml', 'data/yt_world_task.xml',
'views/yt_world_views.xml', 'views/yt_world_views.xml',
+241 -1
View File
@@ -2,7 +2,8 @@
import json import json
import logging import logging
import re import re
from odoo import http import random
from odoo import http, fields
from odoo.http import request, Response from odoo.http import request, Response
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -297,6 +298,245 @@ class YtWorldApi(http.Controller):
return self._records_json('yt.codex.race', return self._records_json('yt.codex.race',
['code','name','icon','layer','traits','description']) ['code','name','icon','layer','traits','description'])
@http.route('/yt_world/api/codex/talents', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def codex_talents(self, **kw):
"""返回天赋图鉴配置(含前置天赋 code)。"""
recs = request.env['yt.codex.talent'].sudo().search([], order='sequence, id')
out = []
for r in recs:
out.append({
'code': r.code,
'name': r.name,
'icon': r.icon or '🌟',
'quality': r.quality or 'common',
'trigger': r.trigger or '被动',
'effect': r.effect or '',
'description': r.description or '',
'max_level': r.max_level or 1,
'cost_type': r.cost_type or 'point',
'cost_value': r.cost_value or 0,
'unlock_condition': r.unlock_condition or '',
'prerequisite_code': r.prerequisite_talent_id.code if r.prerequisite_talent_id else None,
'sequence': r.sequence or 10,
})
return _json(out)
# ---------- 角色天赋系统 ----------
def _serialize_char_talents(self, char):
"""把角色已拥有的天赋序列化成前端可用结构。"""
owned = {}
for t in char.talent_ids:
owned[t.talent_id.code] = {
'id': t.id,
'level': t.level or 1,
'active': t.active,
'acquired_at': fields.Datetime.to_string(t.acquired_at) if t.acquired_at else None,
}
all_talents = request.env['yt.codex.talent'].sudo().search([])
talents = []
for tal in all_talents:
info = owned.get(tal.code, {})
talents.append({
'code': tal.code,
'name': tal.name,
'icon': tal.icon or '🌟',
'quality': tal.quality or 'common',
'trigger': tal.trigger or '被动',
'effect': tal.effect or '',
'description': tal.description or '',
'max_level': tal.max_level or 1,
'cost_type': tal.cost_type or 'point',
'cost_value': tal.cost_value or 0,
'unlock_condition': tal.unlock_condition or '',
'prerequisite_code': tal.prerequisite_talent_id.code if tal.prerequisite_talent_id else None,
'owned': bool(info),
'level': info.get('level', 0),
'active': info.get('active', False),
})
return talents
@http.route('/yt_world/api/char/talents', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def char_talents(self, **kw):
"""取当前角色天赋状态(图鉴 + 拥有情况)。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
char = self._get_char(world)
return _json({
'ok': True,
'talent_points': char.talent_points or 0,
'gold': char.gold or 0,
'talents': self._serialize_char_talents(char),
})
@http.route('/yt_world/api/char/talent/activate', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def char_talent_activate(self, **kw):
"""激活天赋 body: {"code": "talent_xxx"}。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
code = (body.get('code') or '').strip()
if not code:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
Talent = request.env['yt.codex.talent'].sudo()
CharTalent = request.env['yt.world.character.talent'].sudo()
talent = Talent.search([('code', '=', code)], limit=1)
if not talent:
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
existing = CharTalent.search([
('character_id', '=', char.id),
('talent_id', '=', talent.id)
], limit=1)
if existing:
return _json({'ok': False, 'error': 'already_owned', 'code': code})
# 前置天赋校验
if talent.prerequisite_talent_id:
pre = CharTalent.search([
('character_id', '=', char.id),
('talent_id', '=', talent.prerequisite_talent_id.id),
('active', '=', True)
], limit=1)
if not pre:
return _json({'ok': False, 'error': 'prereq_unmet',
'prerequisite_code': talent.prerequisite_talent_id.code})
cost = talent.cost_value or 0
if talent.cost_type == 'point':
if (char.talent_points or 0) < cost:
return _json({'ok': False, 'error': 'no_points',
'need': cost, 'have': char.talent_points or 0})
char.talent_points = (char.talent_points or 0) - cost
elif talent.cost_type == 'gold':
if (char.gold or 0) < cost:
return _json({'ok': False, 'error': 'no_gold',
'need': cost, 'have': char.gold or 0})
char.gold = (char.gold or 0) - cost
CharTalent.create({
'character_id': char.id,
'talent_id': talent.id,
'level': 1,
'active': True,
})
_logger.info('yt.talent.activate uid=%s code=%s cost=%s',
request.session.uid, code, cost)
return _json({
'ok': True,
'code': code,
'level': 1,
'talent_points': char.talent_points or 0,
'gold': char.gold or 0,
})
@http.route('/yt_world/api/char/talent/upgrade', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def char_talent_upgrade(self, **kw):
"""升级天赋 body: {"code": "talent_xxx"}。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
code = (body.get('code') or '').strip()
if not code:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
Talent = request.env['yt.codex.talent'].sudo()
CharTalent = request.env['yt.world.character.talent'].sudo()
talent = Talent.search([('code', '=', code)], limit=1)
if not talent:
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
rec = CharTalent.search([
('character_id', '=', char.id),
('talent_id', '=', talent.id)
], limit=1)
if not rec:
return _json({'ok': False, 'error': 'not_owned', 'code': code})
res = rec.do_upgrade()
if not res.get('ok'):
return _json(res)
return _json({
'ok': True,
'code': code,
'level': res['level'],
'talent_points': char.talent_points or 0,
'gold': char.gold or 0,
})
@http.route('/yt_world/api/char/talent/draw', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def char_talent_draw(self, **kw):
"""新手抽天赋:返回 5 张随机未拥有的天赋牌(前端先展示牌背,翻牌时再免费激活)。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
char = self._get_char(world)
owned_codes = char.talent_ids.mapped('talent_id.code')
pool = request.env['yt.codex.talent'].sudo().search([('code', 'not in', owned_codes)])
data = pool.read(['code', 'name', 'icon', 'quality', 'trigger',
'effect', 'description', 'max_level'])
random.shuffle(data)
return _json({'ok': True, 'deck': data[:5]})
@http.route('/yt_world/api/char/talent/draw/activate', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def char_talent_draw_activate(self, **kw):
"""新手抽卡翻牌激活:免费、免前置、免消耗。body: {"code": "talent_xxx"}。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
code = (body.get('code') or '').strip()
if not code:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
Talent = request.env['yt.codex.talent'].sudo()
CharTalent = request.env['yt.world.character.talent'].sudo()
talent = Talent.search([('code', '=', code)], limit=1)
if not talent:
return _json({'ok': False, 'error': 'talent_not_found', 'code': code})
existing = CharTalent.search([
('character_id', '=', char.id),
('talent_id', '=', talent.id)
], limit=1)
if existing:
return _json({'ok': False, 'error': 'already_owned', 'code': code})
CharTalent.create({
'character_id': char.id,
'talent_id': talent.id,
'level': 1,
'active': True,
})
return _json({'ok': True, 'code': code, 'level': 1,
'name': talent.name, 'icon': talent.icon or '🌟'})
# ============================================================ # ============================================================
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通) # 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
# ============================================================ # ============================================================
+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="talent_ancient_blood" model="yt.codex.talent">
<field name="code">talent_ancient_blood</field>
<field name="name">先民血脉</field>
<field name="icon">🩸</field>
<field name="quality">mythic</field>
<field name="trigger">被动</field>
<field name="effect">生命回复+20%;濒死时触发先民护盾,5秒内免伤</field>
<field name="description">流淌着远古先民的血脉,身体拥有超乎寻常的自愈与守护之力。传说中先民以此血脉抵御了灭世之灾。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">10</field>
</record>
<record id="talent_dragon_soul" model="yt.codex.talent">
<field name="code">talent_dragon_soul</field>
<field name="name">龙裔之魂</field>
<field name="icon">🐉</field>
<field name="quality">mythic</field>
<field name="trigger">被动</field>
<field name="effect">火焰抗性+50%;攻击附带灼烧效果</field>
<field name="description">体内沉睡着巨龙的灵魂碎片,对火焰有着天然的亲和与免疫,攻击时能唤出龙息灼烧敌人。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">20</field>
</record>
<record id="talent_star_resonance" model="yt.codex.talent">
<field name="code">talent_star_resonance</field>
<field name="name">星辰共鸣</field>
<field name="icon">✨</field>
<field name="quality">legendary</field>
<field name="trigger">被动</field>
<field name="effect">法力回复+15%;夜间额外+10%</field>
<field name="description">与星辰之力产生共鸣,能够汲取星光转化为法力。夜幕降临时,这份共鸣尤为强烈。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">30</field>
</record>
<record id="talent_beast_king" model="yt.codex.talent">
<field name="code">talent_beast_king</field>
<field name="name">兽王本能</field>
<field name="icon">🐾</field>
<field name="quality">epic</field>
<field name="trigger">被动</field>
<field name="effect">驯养生物好感度+30%;生物产出+20%</field>
<field name="description">天生与野兽亲和,能感知它们的情绪与需求。驯养的生物更加忠诚,产出也更加丰厚。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">40</field>
</record>
<record id="talent_smith_hand" model="yt.codex.talent">
<field name="code">talent_smith_hand</field>
<field name="name">铁匠之手</field>
<field name="icon">🔨</field>
<field name="quality">rare</field>
<field name="trigger">被动</field>
<field name="effect">加工品品质+1阶;加工成功率+10%</field>
<field name="description">双手有着与生俱来的锻造天赋,加工材料时更加精准,成品的品质也往往超出预期。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">50</field>
</record>
<record id="talent_forest_walker" model="yt.codex.talent">
<field name="code">talent_forest_walker</field>
<field name="name">林间行者</field>
<field name="icon">🌿</field>
<field name="quality">rare</field>
<field name="trigger">被动</field>
<field name="effect">森林地形移速+25%;隐蔽+15%</field>
<field name="description">在密林中如鱼得水,脚步轻盈无声,仿佛与森林融为一体。敌人很难在林间发现你的踪迹。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">60</field>
</record>
<record id="talent_ore_sense" model="yt.codex.talent">
<field name="code">talent_ore_sense</field>
<field name="name">矿脉感应</field>
<field name="icon">⛏️</field>
<field name="quality">uncommon</field>
<field name="trigger">被动</field>
<field name="effect">发现稀有矿脉概率+20%</field>
<field name="description">对地底的矿脉有着敏锐的直觉,总能在别人忽略的地方发现珍贵的矿石。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">70</field>
</record>
<record id="talent_harvest_blessing" model="yt.codex.talent">
<field name="code">talent_harvest_blessing</field>
<field name="name">丰收祝福</field>
<field name="icon">🌾</field>
<field name="quality">uncommon</field>
<field name="trigger">被动</field>
<field name="effect">农作物产量+15%;生长速度+10%</field>
<field name="description">受到大地之灵的祝福,经手种植的作物总是长势喜人,收获时也比旁人更加丰盈。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">80</field>
</record>
</odoo>
+2
View File
@@ -7,6 +7,8 @@ from . import yt_world_game
from . import yt_world_task from . import yt_world_task
from . import yt_resource from . import yt_resource
from . import yt_codex from . import yt_codex
from . import yt_codex_talent
from . import yt_world_character_talent
from . import npc from . import npc
from . import npc_relationship from . import npc_relationship
from . import npc_behavior from . import npc_behavior
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class YtCodexTalent(models.Model):
"""图鉴-天赋(全局配置)。每个玩家在 yt.world.character.talent 中拥有
自己的激活记录与等级,但配置本身全局共享。"""
_name = 'yt.codex.talent'
_description = '图鉴-天赋'
_order = 'sequence, id'
name = fields.Char(string='名称', required=True, translate=True)
code = fields.Char(string='编码', required=True, index=True)
icon = fields.Char(string='图标', default='🌟')
quality = fields.Selection([
('common', '普通'),
('uncommon', '优秀'),
('rare', '精良'),
('epic', '史诗'),
('legendary', '传说'),
('mythic', '神话'),
], string='品质', default='common', required=True)
trigger = fields.Char(string='触发方式', default='被动')
effect = fields.Char(string='效果')
description = fields.Char(string='描述')
max_level = fields.Integer(string='最大等级', default=1)
cost_type = fields.Selection([
('point', '天赋点'),
('gold', '金币'),
('exp', '经验'),
('item', '道具'),
], string='升级消耗类型', default='point')
cost_value = fields.Integer(string='每级消耗', default=0)
unlock_condition = fields.Char(string='解锁条件')
prerequisite_talent_id = fields.Many2one(
'yt.codex.talent', string='前置天赋', ondelete='set null',
help='激活本天赋前必须先激活的天赋'
)
sequence = fields.Integer(string='排序', default=10)
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class YtWorldCharacterTalent(models.Model):
"""玩家角色已拥有的天赋记录。绑定 yt.world.character,每个角色独立。"""
_name = 'yt.world.character.talent'
_description = '玩家角色天赋'
_order = 'character_id, talent_id'
character_id = fields.Many2one(
'yt.world.character', string='角色', required=True,
ondelete='cascade', index=True
)
talent_id = fields.Many2one(
'yt.codex.talent', string='天赋', required=True, index=True
)
level = fields.Integer(string='等级', default=1)
active = fields.Boolean(string='已激活', default=True)
acquired_at = fields.Datetime(string='获得时间', default=fields.Datetime.now)
_sql_constraints = [
('uniq_char_talent', 'unique(character_id, talent_id)', '角色不能重复拥有同一天赋'),
]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('level', 1) < 1:
vals['level'] = 1
return super().create(vals_list)
def can_upgrade(self):
"""检查是否还能升级:未达最大等级、角色有足够天赋点。"""
self.ensure_one()
char = self.character_id
talent = self.talent_id
if self.level >= (talent.max_level or 1):
return False, 'max_level'
cost = talent.cost_value or 0
if talent.cost_type == 'point':
if (char.talent_points or 0) < cost:
return False, 'no_points'
elif talent.cost_type == 'gold':
if (char.gold or 0) < cost:
return False, 'no_gold'
return True, None
def do_upgrade(self):
"""执行升级:扣资源、等级+1。调用前须先用 can_upgrade 校验。"""
self.ensure_one()
ok, reason = self.can_upgrade()
if not ok:
return {'ok': False, 'error': reason}
talent = self.talent_id
cost = talent.cost_value or 0
char = self.character_id
if talent.cost_type == 'point':
char.talent_points = (char.talent_points or 0) - cost
elif talent.cost_type == 'gold':
char.gold = (char.gold or 0) - cost
self.level += 1
return {'ok': True, 'level': self.level}
+5
View File
@@ -36,10 +36,15 @@ class YtWorldCharacter(models.Model):
exp = fields.Integer(string='经验', default=0) exp = fields.Integer(string='经验', default=0)
exp_max = fields.Integer(string='升级所需经验', default=100) exp_max = fields.Integer(string='升级所需经验', default=100)
gold = fields.Integer(string='金币', default=100) gold = fields.Integer(string='金币', default=100)
talent_points = fields.Integer(string='天赋点', default=0,
help='用于激活/升级天赋的专用点数')
inventory = fields.Json(string='背包', default=dict) inventory = fields.Json(string='背包', default=dict)
inventory_display = fields.Text(string='背包(可读)', compute='_compute_displays') inventory_display = fields.Text(string='背包(可读)', compute='_compute_displays')
equip = fields.Json(string='装备', default=dict) equip = fields.Json(string='装备', default=dict)
equip_display = fields.Text(string='装备(可读)', compute='_compute_displays') equip_display = fields.Text(string='装备(可读)', compute='_compute_displays')
talent_ids = fields.One2many(
'yt.world.character.talent', 'character_id', string='已拥有天赋'
)
# 任务进度:[{code, progress:{obj_id:have}, state, ts}] # 任务进度:[{code, progress:{obj_id:have}, state, ts}]
# state ∈ in_progress / claimable / done(locked 为前端按前置派生,不落库) # state ∈ in_progress / claimable / done(locked 为前端按前置派生,不落库)
tasks = fields.Json(string='任务进度', default=list) tasks = fields.Json(string='任务进度', default=list)
@@ -1,4 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yt_codex_talent_user,yt.codex.talent user,model_yt_codex_talent,base.group_user,1,0,0,0
access_yt_codex_talent_public,yt.codex.talent public,model_yt_codex_talent,base.group_public,1,0,0,0
access_yt_codex_talent_portal,yt.codex.talent portal,model_yt_codex_talent,base.group_portal,1,0,0,0
access_yt_world_character_talent_user,yt.world.character.talent user,model_yt_world_character_talent,base.group_user,1,1,1,1
access_yt_world_character_talent_public,yt.world.character.talent public,model_yt_world_character_talent,base.group_public,1,0,0,0
access_yt_world_character_talent_portal,yt.world.character.talent portal,model_yt_world_character_talent,base.group_portal,1,1,1,1
access_yt_world_user,yt.world user,model_yt_world,base.group_user,1,1,1,1 access_yt_world_user,yt.world user,model_yt_world,base.group_user,1,1,1,1
access_yt_world_public,yt.world public,model_yt_world,base.group_public,1,1,1,1 access_yt_world_public,yt.world public,model_yt_world,base.group_public,1,1,1,1
access_yt_world_portal,yt.world portal,model_yt_world,base.group_portal,1,1,1,1 access_yt_world_portal,yt.world portal,model_yt_world,base.group_portal,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yt_codex_talent_user yt.codex.talent user model_yt_codex_talent base.group_user 1 0 0 0
3 access_yt_codex_talent_public yt.codex.talent public model_yt_codex_talent base.group_public 1 0 0 0
4 access_yt_codex_talent_portal yt.codex.talent portal model_yt_codex_talent base.group_portal 1 0 0 0
5 access_yt_world_character_talent_user yt.world.character.talent user model_yt_world_character_talent base.group_user 1 1 1 1
6 access_yt_world_character_talent_public yt.world.character.talent public model_yt_world_character_talent base.group_public 1 0 0 0
7 access_yt_world_character_talent_portal yt.world.character.talent portal model_yt_world_character_talent base.group_portal 1 1 1 1
8 access_yt_world_user yt.world user model_yt_world base.group_user 1 1 1 1
9 access_yt_world_public yt.world public model_yt_world base.group_public 1 1 1 1
10 access_yt_world_portal yt.world portal model_yt_world base.group_portal 1 1 1 1
+49
View File
@@ -280,6 +280,55 @@ const API = {
async codexRaces() { async codexRaces() {
return (await fetch(_worldUrl('/yt_world/api/codex/races'), { credentials: _wcred })).json(); return (await fetch(_worldUrl('/yt_world/api/codex/races'), { credentials: _wcred })).json();
}, },
async codexTalents() {
return (await fetch(_worldUrl('/yt_world/api/codex/talents'), { credentials: _wcred })).json();
},
async charTalents() {
const r = await fetch(_worldUrl('/yt_world/api/char/talents'), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
return await r.json();
},
async activateTalent(code) {
const r = await fetch(_worldUrl('/yt_world/api/char/talent/activate'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) {
return { ok: false, error: 'auth' };
}
return await r.json();
},
async upgradeTalent(code) {
const r = await fetch(_worldUrl('/yt_world/api/char/talent/upgrade'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) {
return { ok: false, error: 'auth' };
}
return await r.json();
},
// ---------- 新手抽天赋 ----------
async talentDraw() {
const r = await fetch(_worldUrl('/yt_world/api/char/talent/draw'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) return { ok: false, error: 'auth' };
return await r.json();
},
async talentDrawActivate(code) {
const r = await fetch(_worldUrl('/yt_world/api/char/talent/draw/activate'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) return { ok: false, error: 'auth' };
return await r.json();
},
// ---------- 新手主线任务 ---------- // ---------- 新手主线任务 ----------
async tasks() { async tasks() {
const r = await fetch(_worldUrl('/yt_world/api/tasks'), { credentials: _wcred }); const r = await fetch(_worldUrl('/yt_world/api/tasks'), { credentials: _wcred });
+13
View File
@@ -0,0 +1,13 @@
/* 宇森剧情漫画播放器 —— 全屏遮罩样式 */
#introOverlay{position:fixed;inset:0;z-index:6000;background:#05070d;display:none;align-items:center;justify-content:center;flex-direction:column;opacity:0;transition:opacity .5s ease;user-select:none;}
#introOverlay.active{display:flex;opacity:1;}
#introOverlay .introImg{max-width:92vw;max-height:78vh;border-radius:12px;box-shadow:0 20px 70px rgba(0,0,0,.7);opacity:0;transform:scale(.98);transition:opacity .45s ease,transform .45s ease;pointer-events:none;}
#introOverlay .introImg.show{opacity:1;transform:scale(1);}
#introOverlay .introDots{display:flex;gap:10px;margin-top:22px;}
#introOverlay .introDot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.25);transition:background .3s,transform .3s;}
#introOverlay .introDot.on{background:#4af;transform:scale(1.25);}
#introOverlay .introHint{position:absolute;bottom:26px;left:0;right:0;text-align:center;color:#9fb0c0;font-size:13px;letter-spacing:.5px;transition:opacity .3s;}
#introOverlay .introSkip{position:absolute;top:18px;right:20px;padding:7px 16px;border-radius:20px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);color:#cdd8e6;font-size:13px;cursor:pointer;transition:background .2s;}
#introOverlay .introSkip:hover{background:rgba(255,255,255,.18);}
#introOverlay .introEnter{margin-top:26px;padding:12px 38px;border:none;border-radius:26px;background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px rgba(74,175,255,.4);opacity:0;transform:translateY(10px);pointer-events:none;transition:opacity .4s,transform .4s;}
#introOverlay .introEnter.show{opacity:1;transform:translateY(0);pointer-events:auto;}
+106
View File
@@ -0,0 +1,106 @@
/**
* 宇森剧情漫画播放器
* 用法:
* await playIntroStory(['assets/story/story_01.jpg', 'assets/story/story_02.jpg']);
* 或:
* await playIntroStory(['assets/story/story_03.jpg'], { enterText: '进入世界 ▸', holdMs: 5000 });
*/
// 确保遮罩 DOM 存在(页面只需引入本脚本与 CSS)
(function ensureOverlay() {
if (document.getElementById('introOverlay')) return;
const overlay = document.createElement('div');
overlay.id = 'introOverlay';
overlay.innerHTML =
'<div class="introSkip" id="introSkip">跳过剧情 ▸</div>' +
'<img class="introImg" id="introImg" alt="剧情" />' +
'<button class="introEnter" id="introEnter">踏入宇森世界 ▸</button>' +
'<div class="introDots" id="introDots"></div>' +
'<div class="introHint" id="introHint">点击 / 空格 / → 继续</div>';
document.body.appendChild(overlay);
})();
window.playIntroStory = function playIntroStory(panels, options) {
options = options || {};
const holdMs = options.holdMs || 5000;
const enterText = options.enterText || '踏入宇森世界 ▸';
const onStart = options.onStart || null; // 开始播放前回调,用于隐藏页面原生元素
return new Promise((resolve) => {
const overlay = document.getElementById('introOverlay');
const img = document.getElementById('introImg');
const dotsWrap = document.getElementById('introDots');
const hint = document.getElementById('introHint');
const skipBtn = document.getElementById('introSkip');
const enterBtn = document.getElementById('introEnter');
if (!Array.isArray(panels) || panels.length === 0) {
resolve();
return;
}
if (typeof onStart === 'function') onStart();
enterBtn.textContent = enterText;
let idx = 0, autoTimer = null, done = false, dots = [];
dotsWrap.innerHTML = '';
panels.forEach((_, i) => {
const d = document.createElement('div');
d.className = 'introDot' + (i === 0 ? ' on' : '');
dotsWrap.appendChild(d);
});
dots = dotsWrap.querySelectorAll('.introDot');
function showPanel(i) {
idx = i;
dots.forEach((d, k) => d.classList.toggle('on', k === i));
img.classList.remove('show');
img.onload = () => img.classList.add('show');
img.src = panels[i];
if (img.complete) img.classList.add('show');
const last = (i === panels.length - 1);
enterBtn.classList.toggle('show', last);
hint.style.opacity = last ? '0' : '1';
clearTimeout(autoTimer);
if (!last) autoTimer = setTimeout(nextPanel, holdMs);
}
function nextPanel() {
if (done || idx >= panels.length - 1) return;
showPanel(idx + 1);
}
function finish() {
if (done) return;
done = true;
clearTimeout(autoTimer);
document.removeEventListener('keydown', onKey);
overlay.classList.remove('active');
setTimeout(() => { overlay.style.display = 'none'; }, 500);
resolve();
}
function onKey(e) {
if (!overlay.classList.contains('active')) return;
if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (idx === panels.length - 1) finish(); else nextPanel();
} else if (e.key === 'Escape') {
finish();
}
}
overlay.addEventListener('click', (e) => {
if (e.target === skipBtn || e.target === enterBtn) return;
nextPanel();
});
skipBtn.addEventListener('click', finish);
enterBtn.addEventListener('click', finish);
document.addEventListener('keydown', onKey);
overlay.style.display = 'flex';
requestAnimationFrame(() => overlay.classList.add('active'));
showPanel(0);
});
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

+76 -31
View File
@@ -234,6 +234,15 @@ body::before{
.md-chips{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px;} .md-chips{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px;}
.md-chip{padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:13px;color:#cdd8e6;} .md-chip{padding:6px 12px;border-radius:8px;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.07);font-size:13px;color:#cdd8e6;}
.md-action-btn{
width:100%;padding:12px 0;border:none;border-radius:10px;
background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;font-size:15px;font-weight:600;cursor:pointer;
box-shadow:0 8px 24px rgba(74,175,255,.25);transition:.2s;
}
.md-action-btn:hover{opacity:.92;transform:translateY(-1px);}
.md-action-btn:disabled{opacity:.55;cursor:not-allowed;transform:none;}
.md-action-error{margin-top:8px;font-size:13px;color:#ff8a8a;text-align:center;}
</style> </style>
</head> </head>
<body> <body>
@@ -324,33 +333,8 @@ const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:
const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' }; const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' };
const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','special']; const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','special'];
// ===== 天赋预置数据(前端硬编码,后端就绪后迁移) ===== // 当前玩家已拥有天赋(从后端加载){ code: { level, active } }
const TALENT_DATA = [ let MY_TALENTS = {};
{ code:'talent_ancient_blood', name:'先民血脉', icon:'🩸', quality:'mythic',
trigger:'被动', effect:'生命回复+20%;濒死时触发先民护盾,5秒内免伤',
desc:'流淌着远古先民的血脉,身体拥有超乎寻常的自愈与守护之力。传说中先民以此血脉抵御了灭世之灾。' },
{ code:'talent_dragon_soul', name:'龙裔之魂', icon:'🐉', quality:'mythic',
trigger:'被动', effect:'火焰抗性+50%;攻击附带灼烧效果',
desc:'体内沉睡着巨龙的灵魂碎片,对火焰有着天然的亲和与免疫,攻击时能唤出龙息灼烧敌人。' },
{ code:'talent_star_resonance', name:'星辰共鸣', icon:'✨', quality:'legendary',
trigger:'被动', effect:'法力回复+15%;夜间额外+10%',
desc:'与星辰之力产生共鸣,能够汲取星光转化为法力。夜幕降临时,这份共鸣尤为强烈。' },
{ code:'talent_beast_king', name:'兽王本能', icon:'🐾', quality:'epic',
trigger:'被动', effect:'驯养生物好感度+30%;生物产出+20%',
desc:'天生与野兽亲和,能感知它们的情绪与需求。驯养的生物更加忠诚,产出也更加丰厚。' },
{ code:'talent_smith_hand', name:'铁匠之手', icon:'🔨', quality:'rare',
trigger:'被动', effect:'加工品品质+1阶;加工成功率+10%',
desc:'双手有着与生俱来的锻造天赋,加工材料时更加精准,成品的品质也往往超出预期。' },
{ code:'talent_forest_walker', name:'林间行者', icon:'🌿', quality:'rare',
trigger:'被动', effect:'森林地形移速+25%;隐蔽+15%',
desc:'在密林中如鱼得水,脚步轻盈无声,仿佛与森林融为一体。敌人很难在林间发现你的踪迹。' },
{ code:'talent_ore_sense', name:'矿脉感应', icon:'⛏️', quality:'uncommon',
trigger:'被动', effect:'发现稀有矿脉概率+20%',
desc:'对地底的矿脉有着敏锐的直觉,总能在别人忽略的地方发现珍贵的矿石。' },
{ code:'talent_harvest_blessing', name:'丰收祝福', icon:'🌾', quality:'uncommon',
trigger:'被动', effect:'农作物产量+15%;生长速度+10%',
desc:'受到大地之灵的祝福,经手种植的作物总是长势喜人,收获时也比旁人更加丰盈。' },
];
// ===== 技能书预置数据(前端硬编码,后端就绪后迁移) ===== // ===== 技能书预置数据(前端硬编码,后端就绪后迁移) =====
const SKILL_BOOK_DATA = [ const SKILL_BOOK_DATA = [
@@ -403,10 +387,14 @@ const BOOK_DATA = [
]; ];
function normTalent(t){ function normTalent(t){
const mine = MY_TALENTS[t.code];
return { return {
type:'talent', id:t.code, cat:'talent', name:t.name, icon:t.icon||'🌟', type:'talent', id:t.code, cat:'talent', name:t.name, icon:t.icon||'🌟',
qual:t.quality||'rare', trigger:t.trigger||'被动', effect:t.effect||'', qual:t.quality||'rare', trigger:t.trigger||'被动', effect:t.effect||'',
desc:t.description||t.desc||'' desc:t.description||t.desc||'', maxLevel:t.max_level||1,
costType:t.cost_type||'point', costValue:t.cost_value||0,
owned:!!mine, level:(mine&&mine.level)||0, active:(mine&&mine.active)||false,
prerequisiteCode:t.prerequisite_code||null
}; };
} }
function normSkillBook(s){ function normSkillBook(s){
@@ -497,15 +485,25 @@ async function loadCodexData(){
const grid = document.getElementById('grid'); const grid = document.getElementById('grid');
grid.innerHTML = '<div class="empty show">正在从服务器加载图鉴数据…</div>'; grid.innerHTML = '<div class="empty show">正在从服务器加载图鉴数据…</div>';
try{ try{
const [res, cre, mon, rac] = await Promise.all([ const [res, cre, mon, rac, talents] = await Promise.all([
API.codexResources(), API.codexCreatures(), API.codexMonsters(), API.codexRaces() API.codexResources(), API.codexCreatures(), API.codexMonsters(), API.codexRaces(), API.codexTalents()
]); ]);
// 同时尝试拉取当前角色天赋状态;未登录或无角色时忽略。
MY_TALENTS = {};
try{
const ct = await API.charTalents();
if(ct && ct.ok && Array.isArray(ct.talents)){
(ct.talents || []).forEach(t => {
if(t.owned) MY_TALENTS[t.code] = { level: t.level || 0, active: t.active || false };
});
}
}catch(e){ /* 未登录,忽略 */ }
ALL_ENTRIES = [ ALL_ENTRIES = [
...(res||[]).map(normResource), ...(res||[]).map(normResource),
...(cre||[]).map(normCreature), ...(cre||[]).map(normCreature),
...(mon||[]).map(normMonster), ...(mon||[]).map(normMonster),
...(rac||[]).map(normRace), ...(rac||[]).map(normRace),
...TALENT_DATA.map(normTalent), ...(talents||[]).map(normTalent),
...SKILL_BOOK_DATA.map(normSkillBook), ...SKILL_BOOK_DATA.map(normSkillBook),
...BOOK_DATA.map(normBook), ...BOOK_DATA.map(normBook),
]; ];
@@ -590,12 +588,14 @@ function renderGrid(){
`; `;
} else if(e.type==='talent'){ } else if(e.type==='talent'){
div.classList.add('q-'+e.qual, 'talent'); div.classList.add('q-'+e.qual, 'talent');
const mine = e.owned ? `<span class="tag" style="background:rgba(139,92,246,.25);color:#d0bcff;">Lv ${e.level}</span>` : '';
div.innerHTML = ` div.innerHTML = `
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span> <span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
<div class="icon">${e.icon}</div> <div class="icon">${e.icon}</div>
<div class="name">${e.name}</div> <div class="name">${e.name}</div>
<div class="tags"> <div class="tags">
<span class="tag">${e.trigger||'被动'}</span> <span class="tag">${e.trigger||'被动'}</span>
${mine}
</div> </div>
`; `;
} else if(e.type==='skill_book'){ } else if(e.type==='skill_book'){
@@ -736,10 +736,23 @@ function showDetail(e){
html += `<div class="md-section"><h3>📋 属性</h3><div class="md-grid">`; html += `<div class="md-section"><h3>📋 属性</h3><div class="md-grid">`;
html += `<div class="gi"><span class="gl">触发方式</span><span class="gv">${e.trigger||'被动'}</span></div>`; html += `<div class="gi"><span class="gl">触发方式</span><span class="gv">${e.trigger||'被动'}</span></div>`;
html += `<div class="gi"><span class="gl">品质</span><span class="gv">${QUALITY_NAMES[e.qual]||'—'}</span></div>`; html += `<div class="gi"><span class="gl">品质</span><span class="gv">${QUALITY_NAMES[e.qual]||'—'}</span></div>`;
html += `<div class="gi"><span class="gl">当前等级</span><span class="gv">${e.owned ? 'Lv ' + e.level : '未激活'}</span></div>`;
html += `<div class="gi"><span class="gl">最大等级</span><span class="gv">Lv ${e.maxLevel||1}</span></div>`;
if(e.costValue){
const costLabel = e.costType === 'gold' ? '金币' : '天赋点';
const costTitle = e.owned ? '升级消耗' : '激活消耗';
html += `<div class="gi"><span class="gl">${costTitle}</span><span class="gv">${e.costValue} ${costLabel}</span></div>`;
}
html += `</div></div>`; html += `</div></div>`;
if(e.effect){ if(e.effect){
html += `<div class="md-section"><h3>⚡ 效果</h3><div class="md-desc" style="color:#f0e6d3;">${e.effect}</div></div>`; html += `<div class="md-section"><h3>⚡ 效果</h3><div class="md-desc" style="color:#f0e6d3;">${e.effect}</div></div>`;
} }
const canUpgrade = e.owned && e.level < e.maxLevel;
if(!e.owned){
html += `<div class="md-section"><button class="md-action-btn" id="talentAction" data-code="${e.id}" data-action="activate">激活天赋</button></div>`;
} else if(canUpgrade){
html += `<div class="md-section"><button class="md-action-btn" id="talentAction" data-code="${e.id}" data-action="upgrade">升级天赋</button></div>`;
}
} }
// 技能书详情 // 技能书详情
@@ -767,6 +780,38 @@ function showDetail(e){
html += '</div>'; html += '</div>';
box.innerHTML = html; box.innerHTML = html;
overlay.classList.add('open'); overlay.classList.add('open');
// 天赋激活/升级按钮事件绑定
const actionBtn = document.getElementById('talentAction');
if(actionBtn){
actionBtn.addEventListener('click', async function onTalentAction(){
const code = this.dataset.code;
const action = this.dataset.action;
this.disabled = true;
const oldText = this.textContent;
this.textContent = '处理中…';
let res;
if(action === 'activate') res = await API.activateTalent(code);
else res = await API.upgradeTalent(code);
if(res && res.ok){
await loadCodexData();
const entry = findEntry(code);
if(entry) showDetail(entry);
}else{
this.disabled = false;
this.textContent = oldText;
const msg = (res && res.error) ? res.error : '失败';
let errBox = document.getElementById('talentActionError');
if(!errBox){
errBox = document.createElement('div');
errBox.id = 'talentActionError';
errBox.className = 'md-action-error';
this.parentNode.appendChild(errBox);
}
errBox.textContent = '操作失败:' + msg;
}
});
}
} }
// ===== 键盘关闭 ===== // ===== 键盘关闭 =====
+21
View File
@@ -88,6 +88,7 @@
.hero .enter-btn{font-size:15px;padding:11px 30px;} .hero .enter-btn{font-size:15px;padding:11px 30px;}
} }
</style> </style>
<link rel="stylesheet" href="assets/story-player.css" />
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -247,5 +248,25 @@ document.getElementById('enterGame').addEventListener('click', async ()=>{
} }
}); });
</script> </script>
<script src="assets/story-player.js"></script>
<script>
// 首页首次访问自动播放前两幕剧情漫画
(function() {
if (localStorage.getItem('seen_intro_part1')) return;
const btn = document.getElementById('enterGame');
if (btn) btn.style.display = 'none';
playIntroStory([
'assets/story/story_01_transmigration.jpg',
'assets/story/story_02_awakening.jpg'
]).then(() => {
localStorage.setItem('seen_intro_part1', '1');
if (btn) btn.style.display = '';
}).catch(e => {
console.warn('首页剧情播放异常', e);
localStorage.setItem('seen_intro_part1', '1');
if (btn) btn.style.display = '';
});
})();
</script>
</body> </body>
</html> </html>
+13 -1
View File
@@ -71,6 +71,7 @@
.submit{padding:13px;font-size:15px;} .submit{padding:13px;font-size:15px;}
} }
</style> </style>
<link rel="stylesheet" href="assets/story-player.css" />
</head> </head>
<body> <body>
<div class="bg-slides"></div> <div class="bg-slides"></div>
@@ -234,7 +235,17 @@
const btn = regForm.querySelector('.submit'); btn.disabled = true; btn.textContent = '注册中…'; const btn = regForm.querySelector('.submit'); btn.disabled = true; btn.textContent = '注册中…';
try { try {
const r = await API.register({ name, login, password }); const r = await API.register({ name, login, password });
if (r.ok){ location.href = nextUrl; } if (r.ok){
// 注册成功后播放第三幕「登记注册」剧情漫画
if (!localStorage.getItem('seen_intro_part2')) {
localStorage.setItem('seen_intro_part1', '1'); // 防止后续流程重复播放前两幕
try {
await playIntroStory(['assets/story/story_03_registration.jpg'], { enterText: '进入世界 ▸' });
} catch (e) { console.warn('注册剧情播放异常', e); }
localStorage.setItem('seen_intro_part2', '1');
}
location.href = nextUrl;
}
else { showMsg(r.error || '注册失败', 'err'); } else { showMsg(r.error || '注册失败', 'err'); }
} catch (err) { } catch (err) {
showMsg('网络错误,请稍后重试', 'err'); showMsg('网络错误,请稍后重试', 'err');
@@ -243,5 +254,6 @@
} }
}); });
</script> </script>
<script src="assets/story-player.js"></script>
</body> </body>
</html> </html>
+462 -29
View File
@@ -377,6 +377,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
.mv-cell.active { background: rgba(255,213,79,.14); border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.45); } .mv-cell.active { background: rgba(255,213,79,.14); border-color: #ffd54f; box-shadow: 0 0 10px rgba(255,213,79,.45); }
.mv-cell .mv-ico { font-size: 30px; line-height: 1; pointer-events: none; } .mv-cell .mv-ico { font-size: 30px; line-height: 1; pointer-events: none; }
.mv-cell .mv-ico-img { width: 44px; height: 44px; object-fit: contain; pointer-events: none; border-radius: 6px; } .mv-cell .mv-ico-img { width: 44px; height: 44px; object-fit: contain; pointer-events: none; border-radius: 6px; }
.mv-cell .mv-ico-char { width: 44px; height: 44px; background-repeat: no-repeat; background-position: center 0; background-size: auto 100%; pointer-events: none; border-radius: 6px; image-rendering: pixelated; }
.mv-cell .mv-nm { font-size: 11px; color: #9fb3c8; pointer-events: none; } .mv-cell .mv-nm { font-size: 11px; color: #9fb3c8; pointer-events: none; }
.mv-cost { font-size: 10px; color: #ffd54f; pointer-events: none; font-weight: 600; } .mv-cost { font-size: 10px; color: #ffd54f; pointer-events: none; font-weight: 600; }
.mv-cost.free { color: #7fdca0; } .mv-cost.free { color: #7fdca0; }
@@ -744,7 +745,84 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
#sceneToolbar{right:8px;gap:5px;padding:6px 7px;top:calc(58px + env(safe-area-inset-top) + 6px);} #sceneToolbar{right:8px;gap:5px;padding:6px 7px;top:calc(58px + env(safe-area-inset-top) + 6px);}
#sceneToolbar .top-icon{width:34px;height:34px;font-size:16px;} #sceneToolbar .top-icon{width:34px;height:34px;font-size:16px;}
} }
/* ===== 我的天赋面板(顶栏 🌟 按钮 → 居中 modal) ===== */
.topBtn{margin-left:8px;display:inline-flex;align-items:center;gap:5px;background:rgba(139,92,246,.18);border:1px solid rgba(139,92,246,.5);color:#e9edf6;padding:7px 14px;border-radius:999px;font:inherit;font-size:14px;font-weight:500;cursor:pointer;user-select:none;transition:background .18s,border-color .18s;pointer-events:auto;}
.topBtn:hover{background:rgba(139,92,246,.32);border-color:rgba(139,92,246,.7);}
.topRight .topBtn[hidden]{display:none;}
#talentPanel{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;background:rgba(4,7,15,.65);backdrop-filter:blur(6px);opacity:0;visibility:hidden;transition:opacity .28s,visibility .28s;padding:20px;}
#talentPanel.open{opacity:1;visibility:visible;}
#talentPanel[hidden]{display:none;}
.tp-box{width:520px;max-width:100%;max-height:88vh;overflow-y:auto;position:relative;background:linear-gradient(180deg,rgba(17,23,42,.98),rgba(10,14,26,.98));border:1px solid rgba(139,92,246,.35);border-radius:18px;box-shadow:0 12px 48px rgba(0,0,0,.6);padding:26px 28px 24px;transform:translateY(20px);transition:transform .3s ease;}
#talentPanel.open .tp-box{transform:translateY(0);}
.tp-close{position:absolute;top:12px;right:14px;width:28px;height:28px;border-radius:8px;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);color:#9aa6c4;font-size:13px;cursor:pointer;transition:.12s;}
.tp-close:hover{background:rgba(239,68,68,.2);color:#ffb4b4;}
.tp-head{display:flex;align-items:center;gap:16px;margin-bottom:18px;}
.tp-icon{font-size:46px;line-height:1;}
.tp-title{font-size:22px;font-weight:800;color:#eef2fb;}
.tp-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;}
.tp-badge{font-size:12px;padding:2px 10px;border-radius:6px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);color:#9fb3cc;}
.tp-list{display:flex;flex-direction:column;gap:12px;}
.tp-item{display:flex;gap:14px;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);}
.tp-ico{font-size:34px;line-height:1;flex:0 0 auto;}
.tp-body{flex:1;min-width:0;}
.tp-name{font-size:16px;font-weight:700;color:#eef2fb;display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
.tp-state{font-size:11px;padding:1px 8px;border-radius:6px;}
.tp-state.active{background:rgba(70,224,122,.18);color:#46e07a;border:1px solid rgba(70,224,122,.4);}
.tp-state.inactive{background:rgba(255,180,80,.15);color:#ffcf86;border:1px solid rgba(255,180,80,.35);}
.tp-lv{font-size:12px;color:#9fb3cc;margin-top:3px;}
.tp-eff{font-size:13px;color:#cdd8e6;line-height:1.6;margin-top:6px;}
.tp-btn{margin-top:10px;padding:8px 16px;border:none;border-radius:9px;background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;font-size:14px;font-weight:600;cursor:pointer;transition:.18s;}
.tp-btn:hover{filter:brightness(1.08);}
.tp-btn:disabled{opacity:.5;cursor:not-allowed;}
.tp-max{color:#fbbf24;font-size:12px;margin-top:8px;}
.tp-empty{text-align:center;padding:30px 10px;color:#7b8ea8;font-size:14px;line-height:1.7;}
.tp-empty a{color:#8b5cf6;}
/* ===== 新手抽天赋引导 ===== */
#talentDraw{position:fixed;inset:0;z-index:1300;display:flex;align-items:flex-end;justify-content:center;background:rgba(4,7,15,.72);backdrop-filter:blur(7px);opacity:0;visibility:hidden;transition:opacity .3s,visibility .3s;padding:28px 18px 40px;}
#talentDraw.open{opacity:1;visibility:visible;}
#talentDraw[hidden]{display:none;}
.td-wrap{width:760px;max-width:100%;display:flex;flex-direction:column;align-items:center;gap:18px;}
.td-npc{display:flex;align-items:flex-end;gap:14px;align-self:flex-start;max-width:100%;}
.td-avatar{width:64px;height:64px;flex:0 0 auto;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:34px;background:radial-gradient(circle at 30% 30%,#a78bfa,#6d28d9);box-shadow:0 0 26px rgba(139,92,246,.6);animation:td-float 3s ease-in-out infinite;}
@keyframes td-float{0%,100%{transform:translateY(0);}50%{transform:translateY(-7px);}}
.td-bubble{position:relative;background:linear-gradient(180deg,rgba(30,38,66,.98),rgba(18,24,44,.98));border:1px solid rgba(139,92,246,.45);border-radius:16px;padding:14px 18px;max-width:560px;color:#e8edf8;font-size:15px;line-height:1.7;box-shadow:0 8px 32px rgba(0,0,0,.5);}
.td-bubble:after{content:"";position:absolute;left:-9px;bottom:18px;border:9px solid transparent;border-right-color:rgba(139,92,246,.45);}
.td-npcname{font-size:12px;color:#a78bfa;margin-bottom:4px;font-weight:700;}
.td-bubble b{color:#c4b5fd;}
.td-cards{display:flex;gap:14px;flex-wrap:wrap;justify-content:center;min-height:168px;}
.td-card{width:120px;height:168px;perspective:900px;cursor:pointer;position:relative;}
.td-card.dealt{animation:td-deal .42s ease both;}
@keyframes td-deal{from{opacity:0;transform:translateY(26px) rotate(-6deg);}to{opacity:1;transform:none;}}
.td-inner{position:relative;width:100%;height:100%;transform-style:preserve-3d;transition:transform .55s cubic-bezier(.2,.8,.3,1);}
.td-card.flipped .td-inner{transform:rotateY(180deg);}
.td-face{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;border-radius:14px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:10px;box-shadow:0 6px 18px rgba(0,0,0,.45);}
.td-back{background:linear-gradient(160deg,#3b2a6b,#1c1340);border:1px solid rgba(167,139,250,.5);color:#c4b5fd;font-size:40px;}
.td-back:after{content:"";position:absolute;inset:10px;border:1px dashed rgba(167,139,250,.35);border-radius:10px;}
.td-front{transform:rotateY(180deg);background:linear-gradient(180deg,rgba(20,26,46,.98),rgba(12,16,30,.98));border:1px solid rgba(255,255,255,.12);}
.td-front .td-ico{font-size:40px;line-height:1;}
.td-front .td-q{font-size:11px;letter-spacing:1px;margin:7px 0 2px;font-weight:700;}
.td-front .td-name{font-size:15px;font-weight:800;color:#fff;}
.td-front .td-eff{font-size:11px;color:#cdd8e6;line-height:1.5;margin-top:6px;padding:0 4px;}
.td-front.got{box-shadow:0 0 0 2px rgba(70,224,122,.7),0 6px 18px rgba(70,224,122,.35);}
.td-front .td-got{position:absolute;top:6px;right:8px;font-size:10px;color:#46e07a;font-weight:700;}
.td-card.done{cursor:default;}
.td-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;justify-content:center;}
.td-btn{padding:10px 22px;border:none;border-radius:10px;background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;font-size:15px;font-weight:700;cursor:pointer;transition:.18s;box-shadow:0 6px 18px rgba(74,175,255,.35);}
.td-btn:hover{filter:brightness(1.08);}
.td-btn.ghost{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.16);box-shadow:none;color:#cdd8e6;}
.td-count{color:#9fb3cc;font-size:13px;}
.td-q-common{color:#9ca3af;} .td-q-uncommon{color:#5fd17a;} .td-q-rare{color:#4aafff;} .td-q-epic{color:#c084fc;} .td-q-legendary{color:#f59e0b;} .td-q-mythic{color:#fb7185;}
/* 抽卡完成动画:未翻牌碎裂 / 已翻牌飞向天赋按钮 */
.td-card.shatter{pointer-events:none;}
.td-card.shatter .td-inner{opacity:0;transform:scale(.92);transition:opacity .22s,transform .22s;}
.shard{position:absolute;width:16px;height:16px;background:linear-gradient(135deg,#a78bfa,#4c1d95);border:1px solid rgba(196,181,253,.35);border-radius:3px;pointer-events:none;opacity:0;left:50%;top:50%;margin:-8px 0 0 -8px;box-shadow:0 0 8px rgba(139,92,246,.5);}
.td-card.shatter .shard{animation:shard-burst .7s cubic-bezier(.2,.7,.2,1) forwards;}
@keyframes shard-burst{0%{opacity:1;transform:translate3d(0,0,0) rotate(0deg) scale(1);}100%{opacity:0;transform:translate3d(var(--tx),var(--ty),0) rotate(var(--rot)) scale(.15);}}
.td-fly-clone{position:fixed;z-index:9999;pointer-events:none;border-radius:14px;overflow:hidden;box-shadow:0 0 28px rgba(139,92,246,.75);transition:transform .75s cubic-bezier(.2,.8,.2,1),opacity .75s;}
.td-fly-clone .td-inner{transition:none !important;}
</style> </style>
<link rel="stylesheet" href="assets/story-player.css" />
</head> </head>
<body> <body>
<div id="authSplash"><div class="auth-spinner"><div id="authSpinner"></div><span>登录校验中…</span></div></div> <div id="authSplash"><div class="auth-spinner"><div id="authSpinner"></div><span>登录校验中…</span></div></div>
@@ -771,6 +849,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<div id="clockLabel">清晨 07:30</div> <div id="clockLabel">清晨 07:30</div>
<div id="calendarLabel">星元 1年 · 春</div> <div id="calendarLabel">星元 1年 · 春</div>
</div> </div>
<button class="topBtn" id="talentBtn" hidden title="我的天赋">🌟 天赋</button>
<div class="userArea" id="userArea" hidden> <div class="userArea" id="userArea" hidden>
<div class="ua-trigger" id="uaTrigger"> <div class="ua-trigger" id="uaTrigger">
<span class="ua-avatar">●</span> <span class="ua-avatar">●</span>
@@ -1781,7 +1860,7 @@ const MICRO_BUILD_EXTRA = [
{ id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png?v=5',name: '酒馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 95, statYield: { satiety: 3 }, costGold: 45 }, { id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png?v=5',name: '酒馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 95, statYield: { satiety: 3 }, costGold: 45 },
{ id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png?v=13', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 4, h: 4, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 }, costGold: 70 }, { id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png?v=13', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 4, h: 4, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 }, costGold: 70 },
{ id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png?v=7',name:'商城', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 }, costGold: 120 }, { id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png?v=7',name:'商城', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 }, costGold: 120 },
{ id: 'farmland', cat: 'business', icon: 'assets/buildings/spring/farmland/farmland.png', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 }, costGold: 10 }, { id: 'farmland', cat: 'business', icon: 'assets/buildings/spring/farmland/farmland.png?v=202608251713', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 }, costGold: 10 },
{ id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 }, costGold: 15 }, { id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 }, costGold: 15 },
{ id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png?v=6', name: '学堂', desc: '授业解惑之所,研读典籍提升领地科研与基础教学产出。', flat: false, w: 3, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3, prestige: 1 }, costGold: 90 }, { id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png?v=6', name: '学堂', desc: '授业解惑之所,研读典籍提升领地科研与基础教学产出。', flat: false, w: 3, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3, prestige: 1 }, costGold: 90 },
{ id: 'library', cat: 'build', icon: 'assets/buildings/spring/library/library_l.png?v=5', name: '图书馆', desc: '收藏典籍与卷轴,提升领地科研与知识产出。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 110, statYield: { research: 4 }, costGold: 110 }, { id: 'library', cat: 'build', icon: 'assets/buildings/spring/library/library_l.png?v=5', name: '图书馆', desc: '收藏典籍与卷轴,提升领地科研与知识产出。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 110, statYield: { research: 4 }, costGold: 110 },
@@ -1789,16 +1868,16 @@ const MICRO_BUILD_EXTRA = [
{ id: 'guild', cat: 'build', icon: 'assets/buildings/spring/guild/guild_l.png?v=4', name: '公会', desc: '冒险者与住民集结之所,可发布委托与招募成员。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 140, statYield: { prestige: 2, coin: 1 }, costGold: 100 }, { id: 'guild', cat: 'build', icon: 'assets/buildings/spring/guild/guild_l.png?v=4', name: '公会', desc: '冒险者与住民集结之所,可发布委托与招募成员。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 140, statYield: { prestige: 2, coin: 1 }, costGold: 100 },
{ id: 'village', cat: 'build', icon: 'assets/buildings/spring/village/village_l.png?v=5', name: '村庄', desc: '族群聚落的核心,提升人口上限与基础产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 150, statYield: { population: 6, food: 1 }, costGold: 200 }, { id: 'village', cat: 'build', icon: 'assets/buildings/spring/village/village_l.png?v=5', name: '村庄', desc: '族群聚落的核心,提升人口上限与基础产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 150, statYield: { population: 6, food: 1 }, costGold: 200 },
// 生物分类:9 种族角色(点击进入"点地面放置"模式,不进入建筑模式) // 生物分类:9 种族角色(点击进入"点地面放置"模式,不进入建筑模式)
{ id: 'race_human', cat: 'bio', icon: 'assets/characters/human/human_thumb.png', name: '人族', isChar: true, race: 'human' }, { id: 'race_human', cat: 'bio', icon: 'assets/characters/human/human_thumb.png', sprite: 'assets/characters/human/human_idle_attack_1x6.png', name: '人族', isChar: true, race: 'human' },
{ id: 'race_demon', cat: 'bio', icon: 'assets/characters/demon/demon_thumb.png', name: '妖族', isChar: true, race: 'demon' }, { id: 'race_demon', cat: 'bio', icon: 'assets/characters/demon/demon_thumb.png', sprite: 'assets/characters/demon/demon_idle_attack_1x6.png', name: '妖族', isChar: true, race: 'demon' },
{ id: 'race_dwarf', cat: 'bio', icon: 'assets/characters/dwarf/dwarf_thumb.png', name: '矮人', isChar: true, race: 'dwarf' }, { id: 'race_dwarf', cat: 'bio', icon: 'assets/characters/dwarf/dwarf_thumb.png', sprite: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', name: '矮人', isChar: true, race: 'dwarf' },
{ id: 'race_elf', cat: 'bio', icon: 'assets/characters/elf/elf_thumb.png', name: '精灵', isChar: true, race: 'elf' }, { id: 'race_elf', cat: 'bio', icon: 'assets/characters/elf/elf_thumb.png', sprite: 'assets/characters/elf/elf_idle_attack_1x6.png', name: '精灵', isChar: true, race: 'elf' },
{ id: 'race_dragon', cat: 'bio', icon: 'assets/characters/dragon/dragon_thumb.png', name: '龙族', isChar: true, race: 'dragon' }, { id: 'race_dragon', cat: 'bio', icon: 'assets/characters/dragon/dragon_thumb.png', sprite: 'assets/characters/dragon/dragon_idle_attack_1x6.png', name: '龙族', isChar: true, race: 'dragon' },
{ id: 'race_undead', cat: 'bio', icon: 'assets/characters/undead/undead_thumb.png', name: '不死族', isChar: true, race: 'undead' }, { id: 'race_undead', cat: 'bio', icon: 'assets/characters/undead/undead_thumb.png', sprite: 'assets/characters/undead/undead_idle_attack_1x6.png', name: '不死族', isChar: true, race: 'undead' },
{ id: 'race_orc', cat: 'bio', icon: 'assets/characters/orc/orc_thumb.png', name: '兽人', isChar: true, race: 'orc' }, { id: 'race_orc', cat: 'bio', icon: 'assets/characters/orc/orc_thumb.png', sprite: 'assets/characters/orc/orc_idle_attack_1x6.png', name: '兽人', isChar: true, race: 'orc' },
{ id: 'race_angel', cat: 'bio', icon: 'assets/characters/angel/angel_thumb.png', name: '天使', isChar: true, race: 'angel' }, { id: 'race_angel', cat: 'bio', icon: 'assets/characters/angel/angel_thumb.png', sprite: 'assets/characters/angel/angel_idle_attack_1x6.png', name: '天使', isChar: true, race: 'angel' },
{ id: 'race_werewolf',cat: 'bio', icon: 'assets/characters/werewolf/werewolf_thumb.png', name: '狼人', isChar: true, race: 'werewolf' }, { id: 'race_werewolf',cat: 'bio', icon: 'assets/characters/werewolf/werewolf_thumb.png', sprite: 'assets/characters/werewolf/werewolf_idle_attack_1x6.png', name: '狼人', isChar: true, race: 'werewolf' },
{ id: 'race_golem', cat: 'bio', icon: 'assets/characters/golem/golem_thumb.png', name: '石傀儡', isChar: true, race: 'golem' }, { id: 'race_golem', cat: 'bio', icon: 'assets/characters/golem/golem_thumb.png', sprite: 'assets/characters/golem/golem_idle_attack_1x6.png', name: '石傀儡', isChar: true, race: 'golem' },
]; ];
// 建筑交互配置(数据驱动,与后端 BUILD_EFFECTS 的 (code,action) 对应)。 // 建筑交互配置(数据驱动,与后端 BUILD_EFFECTS 的 (code,action) 对应)。
@@ -1934,7 +2013,19 @@ async function microLoadCatalog() {
const t = byCode[it.code]; const t = byCode[it.code];
if (!t) return; if (!t) return;
t.name = it.name || t.name; t.name = it.name || t.name;
t.icon = it.icon || t.icon; // 后端 icon 是干净 URL(无 ?v=),前端兜底 icon 可能带缓存号;
// 若两者指向同一文件,保留前端 ?v= 缓存号,避免被浏览器旧 immutable 缓存坑。
if (it.icon) {
const baseOf = u => { const m = String(u || '').match(/^[^?]+/); return m ? m[0] : ''; };
const backendBase = baseOf(it.icon);
const frontendBase = baseOf(t.icon);
const frontendHasVersion = (t.icon || '').includes('?');
if (frontendBase && backendBase === frontendBase && frontendHasVersion) {
// 同一张图:保留前端带 ?v= 的 URL,实现 cache-bust
} else {
t.icon = it.icon;
}
}
t.desc = it.desc || t.desc; t.desc = it.desc || t.desc;
if (it.w != null && t.w == null) t.w = it.w; // 前端兜底已定义的尺寸优先,避免后端旧数据把 2×2 顶成 3×3 if (it.w != null && t.w == null) t.w = it.w; // 前端兜底已定义的尺寸优先,避免后端旧数据把 2×2 顶成 3×3
if (it.h != null && t.h == null) t.h = it.h; if (it.h != null && t.h == null) t.h = it.h;
@@ -1946,7 +2037,7 @@ async function microLoadCatalog() {
if (Array.isArray(it.actions) && it.actions.length) t.actions = it.actions; // 后端动作优先 if (Array.isArray(it.actions) && it.actions.length) t.actions = it.actions; // 后端动作优先
}); });
microCatalogLoaded = true; microCatalogLoaded = true;
microLoadIcons(); // 目录覆盖 icon 后,用最终(干净 URL)的图标重新预加载,避免带 ?v= 缓存号与后端干净 URL 不一致导致建筑不显示 microLoadIcons(); // 目录覆盖 icon 后,用最终(可能带 ?v= 缓存号)的图标重新预加载
} catch (e) { /* 未登录/网络不可达:保留兜底常量 */ } } catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
} }
@@ -5478,8 +5569,8 @@ function applyWorldState(ws) {
} }
// 背景音乐本地偏好(localStorage) // 背景音乐本地偏好(localStorage)
function loadWorldBGMSetting() { function loadWorldBGMSetting() {
try { return localStorage.getItem('world_bgm_enabled') !== 'false'; } try { return localStorage.getItem('world_bgm_enabled') === 'true'; }
catch (e) { return true; } catch (e) { return false; }
} }
function saveWorldBGMSetting(enabled) { function saveWorldBGMSetting(enabled) {
try { localStorage.setItem('world_bgm_enabled', enabled ? 'true' : 'false'); } catch (e) {} try { localStorage.setItem('world_bgm_enabled', enabled ? 'true' : 'false'); } catch (e) {}
@@ -5534,15 +5625,10 @@ function initBGM() {
audio.volume = 0.35; audio.volume = 0.35;
// 预加载音频 // 预加载音频
try { audio.load(); } catch (e) {} try { audio.load(); } catch (e) {}
// 进入页面立即尝试自动播放:浏览器若已对该域名授予自动播放许可(用户此前有交互), // [2026-08-26] 临时停用背景音乐自动播放,由用户在「设置 → 背景音乐」中手动开启。
// 这里会直接出声;无需等任何点击。 // 如需恢复自动播放,去掉下面两行注释即可:
if (loadWorldBGMSetting()) { // if (loadWorldBGMSetting()) { applyBGMSetting(); }
applyBGMSetting(); // ['click','keydown','touchstart'].forEach(evt => document.addEventListener(evt, tryPlayBGMOnce, { once:true, passive:true }));
}
// 兜底:若上述自动播放被浏览器拦截(如 Safari 严格策略),则在用户首次交互时补播。
['click', 'keydown', 'touchstart'].forEach(evt => {
document.addEventListener(evt, tryPlayBGMOnce, { once: true, passive: true });
});
} }
// 相机/UI 偏好收集 + 防抖保存 // 相机/UI 偏好收集 + 防抖保存
@@ -6152,8 +6238,13 @@ function microRenderBuildPanel() {
}); });
const items = visibleInCat(microBuildCat); const items = visibleInCat(microBuildCat);
gridEl.innerHTML = items.length ? items.map(it => { gridEl.innerHTML = items.length ? items.map(it => {
const isImg = typeof it.icon === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(it.icon); let ico;
const ico = isImg ? `<img src="${it.icon}" class="mv-ico-img" alt="">` : `<span class="mv-ico">${it.icon}</span>`; if (it.isChar && it.sprite) {
ico = `<div class="mv-ico-char" style="background-image:url('${it.sprite}')"></div>`;
} else {
const isImg = typeof it.icon === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(it.icon);
ico = isImg ? `<img src="${it.icon}" class="mv-ico-img" alt="">` : `<span class="mv-ico">${it.icon}</span>`;
}
const cost = (!it.isChar) ? (it.costGold || 0) : null; const cost = (!it.isChar) ? (it.costGold || 0) : null;
const costHtml = cost == null ? '' : const costHtml = cost == null ? '' :
(cost > 0 ? `<span class="mv-cost">🪙 ${cost}</span>` : `<span class="mv-cost free">免费</span>`); (cost > 0 ? `<span class="mv-cost">🪙 ${cost}</span>` : `<span class="mv-cost free">免费</span>`);
@@ -6670,6 +6761,7 @@ generateMap();
// 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖 // 注意:地表改动不再走 localStorage,统一由登录闸门拉取后端存档 applyWorldState 覆盖
init(); init();
</script> </script>
<script src="assets/story-player.js"></script>
<!-- 右下角导航控件:方向 + 缩放(替代原鼠标拖拽+滚轮) --> <!-- 右下角导航控件:方向 + 缩放(替代原鼠标拖拽+滚轮) -->
<div id="navControls"> <div id="navControls">
<button class="nc-nw" data-cmd="nw">↖</button> <button class="nc-nw" data-cmd="nw">↖</button>
@@ -6799,8 +6891,328 @@ window.addEventListener('keydown', e => {
// 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。 // 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。
if (typeof fitWorld === 'function') fitWorld(); if (typeof fitWorld === 'function') fitWorld();
</script> </script>
<script src="assets/game-api.js"></script> <script src="assets/game-api.js?v=20260825"></script>
<script src="assets/characters.js"></script>
<!-- ===== 我的天赋面板(顶栏 🌟 按钮触发,展示玩家已拥有的天赋) ===== -->
<div id="talentPanel" hidden>
<div class="tp-box" id="talentBox">
<button class="tp-close" id="talentClose" type="button" aria-label="关闭">✕</button>
<div class="tp-head">
<div class="tp-icon">🌟</div>
<div class="tp-info">
<div class="tp-title">我的天赋</div>
<div class="tp-meta">
<span class="tp-badge" id="talentPointBadge">天赋点 0</span>
<span class="tp-badge" id="talentGoldBadge">金币 0</span>
</div>
</div>
</div>
<div class="tp-list" id="talentList"></div>
</div>
</div>
<!-- ===== 新手抽天赋引导(NPC 气泡 + 翻牌) ===== -->
<div id="talentDraw" hidden>
<div class="td-wrap">
<div class="td-npc">
<div class="td-avatar">🔮</div>
<div class="td-bubble">
<div class="td-npcname">引导精灵 · 星澜</div>
<div id="tdBubbleText">你好,勇者!命运之牌已为你备好。点击下方「开始抽牌」,从五张天赋牌中翻开三张,它们将化作你独有的天赋。</div>
</div>
</div>
<div class="td-cards" id="tdCards"></div>
<div class="td-actions">
<span class="td-count" id="tdCount"></span>
<button class="td-btn" id="tdStart" type="button">开始抽牌</button>
<button class="td-btn ghost" id="tdLater" type="button" hidden>稍后再说</button>
<button class="td-btn" id="tdFinish" type="button" hidden>完成</button>
</div>
</div>
</div>
<script>
// ===== 我的天赋面板逻辑 =====
async function openTalentPanel(){
const panel = document.getElementById('talentPanel');
const list = document.getElementById('talentList');
if(!panel || !list) return;
panel.hidden = false;
list.innerHTML = '<div class="tp-empty">正在加载…</div>';
requestAnimationFrame(()=> panel.classList.add('open'));
let data;
try{ data = await API.charTalents(); }
catch(e){ list.innerHTML = '<div class="tp-empty">加载异常:' + (e && e.message ? e.message : e) + '</div>'; return; }
if(!data || data.ok === false){
list.innerHTML = '<div class="tp-empty">加载失败:' + ((data && data.error) || '未知错误') + '</div>';
return;
}
const pts = document.getElementById('talentPointBadge');
const gld = document.getElementById('talentGoldBadge');
if(pts) pts.textContent = '天赋点 ' + (data.talent_points || 0);
if(gld) gld.textContent = '金币 ' + (data.gold || 0);
const owned = (data.talents || []).filter(t => t.owned);
if(!owned.length){
list.innerHTML = '<div class="tp-empty">尚未激活任何天赋<br>' +
'<button class="tp-btn" id="tpDrawBtn" style="margin-top:14px;">🎴 新手抽天赋</button><br>' +
'<a href="codex.html" style="display:inline-block;margin-top:10px;">或前往 图鉴 → 天赋</a></div>';
const db = document.getElementById('tpDrawBtn');
if(db) db.addEventListener('click', () => { closeTalentPanel(); openTalentDraw(); });
return;
}
list.innerHTML = '';
owned.forEach(t => {
const div = document.createElement('div');
div.className = 'tp-item';
const maxed = (t.level || 1) >= (t.max_level || 1);
const state = t.active
? '<span class="tp-state active">已激活</span>'
: '<span class="tp-state inactive">未激活</span>';
const action = maxed
? '<div class="tp-max">已达最高等级</div>'
: '<button class="tp-btn" data-code="' + t.code + '">升级至 Lv ' + ((t.level || 1) + 1) + '</button>';
div.innerHTML =
'<div class="tp-ico">' + (t.icon || '🌟') + '</div>' +
'<div class="tp-body">' +
'<div class="tp-name">' + t.name + ' ' + state + '</div>' +
'<div class="tp-lv">等级 ' + (t.level || 1) + ' / ' + (t.max_level || 1) + '</div>' +
(t.effect ? '<div class="tp-eff">' + t.effect + '</div>' : '') +
action +
'</div>';
list.appendChild(div);
});
list.querySelectorAll('.tp-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '升级中…';
const res = await API.upgradeTalent(btn.dataset.code);
if(res && res.ok){ openTalentPanel(); }
else { btn.disabled = false; btn.textContent = '升级失败:' + ((res && res.error) || ''); }
});
});
}
function closeTalentPanel(){
const panel = document.getElementById('talentPanel');
if(!panel) return;
panel.classList.remove('open');
setTimeout(() => { panel.hidden = true; }, 280);
}
(function bindTalentPanel(){
const btn = document.getElementById('talentBtn');
if(btn) btn.addEventListener('click', openTalentPanel);
const close = document.getElementById('talentClose');
if(close) close.addEventListener('click', closeTalentPanel);
const panel = document.getElementById('talentPanel');
if(panel) panel.addEventListener('click', e => { if(e.target === panel) closeTalentPanel(); });
document.addEventListener('keydown', e => {
if(e.key === 'Escape'){
const p = document.getElementById('talentPanel');
if(p && p.classList.contains('open')) closeTalentPanel();
}
});
})();
</script>
<script>
// ===== 新手抽天赋引导逻辑 =====
const TD_MAX = 3;
const QCLASS = { common:'td-q-common', uncommon:'td-q-uncommon', rare:'td-q-rare', epic:'td-q-epic', legendary:'td-q-legendary', mythic:'td-q-mythic' };
const QLABEL = { common:'普通', uncommon:'优秀', rare:'精良', epic:'史诗', legendary:'传说', mythic:'神话' };
let tdDeck = [], tdFlipped = 0, tdNames = [];
function tdQualityClass(q){ return QCLASS[q] || 'td-q-common'; }
async function openTalentDraw(){
const ov = document.getElementById('talentDraw');
if(!ov) return;
tdDeck = []; tdFlipped = 0; tdNames = [];
document.getElementById('tdCards').innerHTML = '';
document.getElementById('tdCount').textContent = '';
document.getElementById('tdBubbleText').textContent =
'你好,勇者!命运之牌已为你备好。点击下方「开始抽牌」,从五张天赋牌中翻开三张,它们将化作你独有的天赋。';
document.getElementById('tdStart').hidden = false;
document.getElementById('tdLater').hidden = true;
document.getElementById('tdFinish').hidden = true;
ov.hidden = false;
requestAnimationFrame(()=> ov.classList.add('open'));
}
function closeTalentDraw(){
const ov = document.getElementById('talentDraw');
if(!ov) return;
ov.classList.remove('open');
setTimeout(()=>{ ov.hidden = true; }, 300);
}
async function tdStart(){
const cardsEl = document.getElementById('tdCards');
cardsEl.innerHTML = '<div class="tp-empty">正在抽取命运之牌…</div>';
document.getElementById('tdStart').hidden = true;
let d;
try{ d = await API.talentDraw(); }
catch(e){ cardsEl.innerHTML = '<div class="tp-empty">抽取失败,请重试</div>'; document.getElementById('tdStart').hidden = false; return; }
if(!d || d.ok === false){
cardsEl.innerHTML = '<div class="tp-empty">暂无可抽取的天赋(已全部拥有)</div>';
document.getElementById('tdLater').hidden = false;
return;
}
tdDeck = d.deck || [];
if(!tdDeck.length){
cardsEl.innerHTML = '<div class="tp-empty">暂无可抽取的天赋</div>';
document.getElementById('tdLater').hidden = false;
return;
}
tdFlipped = 0; tdNames = [];
cardsEl.innerHTML = '';
tdDeck.forEach((t, i) => {
const card = document.createElement('div');
card.className = 'td-card dealt';
card.style.animationDelay = (i * 0.08) + 's';
card.innerHTML =
'<div class="td-inner">' +
'<div class="td-face td-back">🌟</div>' +
'<div class="td-face td-front">' +
'<div class="td-got" hidden>已获得</div>' +
'<div class="td-ico">' + (t.icon || '🌟') + '</div>' +
'<div class="td-q ' + tdQualityClass(t.quality) + '">' + (QLABEL[t.quality] || '普通') + '</div>' +
'<div class="td-name">' + t.name + '</div>' +
(t.effect ? '<div class="td-eff">' + t.effect + '</div>' : '') +
'</div>' +
'</div>';
card.addEventListener('click', () => tdFlip(card, t));
cardsEl.appendChild(card);
});
document.getElementById('tdBubbleText').textContent =
'点击卡牌翻面,最多翻开 ' + TD_MAX + ' 张,翻开即永久获得该天赋。';
document.getElementById('tdLater').hidden = false;
document.getElementById('tdFinish').hidden = false;
tdUpdateCount();
}
async function tdFlip(card, t){
if(card.classList.contains('flipped') || card.classList.contains('done')) return;
if(tdFlipped >= TD_MAX){
document.getElementById('tdBubbleText').textContent = '你已经翻开了 ' + TD_MAX + ' 张命运之牌,点击「完成」确认。';
return;
}
card.classList.add('flipped');
tdFlipped++;
tdNames.push(t.name);
tdUpdateCount();
const res = await API.talentDrawActivate(t.code);
const front = card.querySelector('.td-front');
if(res && res.ok){
card.classList.add('done');
const got = card.querySelector('.td-got');
if(got) got.hidden = false;
if(front) front.classList.add('got');
} else {
card.classList.remove('flipped');
tdFlipped--; tdNames.pop();
tdUpdateCount();
document.getElementById('tdBubbleText').textContent =
'这张牌不愿被唤醒(' + ((res && res.error) || '未知') + '),换一张试试。';
}
if(tdFlipped >= TD_MAX){
document.getElementById('tdBubbleText').textContent =
'命运已定!你获得了:' + tdNames.join('、') + '。它们已记入「我的天赋」。';
}
}
function tdUpdateCount(){
const el = document.getElementById('tdCount');
if(el) el.textContent = '已翻开 ' + tdFlipped + ' / ' + TD_MAX + ' 张';
}
function tdFinish(){
const finishBtn = document.getElementById('tdFinish');
if(finishBtn){ finishBtn.disabled = true; finishBtn.textContent = '收集中…'; }
const cards = Array.from(document.querySelectorAll('#tdCards .td-card'));
const flipped = cards.filter(c => c.classList.contains('flipped'));
const unflipped = cards.filter(c => !c.classList.contains('flipped'));
const target = document.getElementById('talentBtn');
const targetRect = target ? target.getBoundingClientRect() : null;
let completed = 0;
const total = flipped.length + (unflipped.length ? 1 : 0);
if(total === 0){ doTalentDrawWrap(); return; }
function checkDone(){ if(++completed >= total) doTalentDrawWrap(); }
function doTalentDrawWrap(){
localStorage.setItem('talent_draw_done', '1');
closeTalentDraw();
setTimeout(()=>{
const tp = document.getElementById('talentPanel');
if(tp && tp.classList.contains('open')){ /* 已经在打开状态,刷新数据 */ openTalentPanel(); }
else{ openTalentPanel(); }
try{ if(typeof API.taskProgress === 'function') API.taskProgress('talent_draw', { count: tdFlipped }); }catch(e){}
}, 220);
}
// 已翻开的牌:飞向顶栏「天赋」按钮
flipped.forEach((card, i) => {
const rect = card.getBoundingClientRect();
const clone = card.cloneNode(true);
clone.classList.add('td-fly-clone');
clone.style.left = rect.left + 'px';
clone.style.top = rect.top + 'px';
clone.style.width = rect.width + 'px';
clone.style.height = rect.height + 'px';
clone.style.opacity = '1';
document.body.appendChild(clone);
card.style.opacity = '0';
setTimeout(()=>{
if(targetRect){
const tx = targetRect.left + targetRect.width/2 - (rect.left + rect.width/2);
const ty = targetRect.top + targetRect.height/2 - (rect.top + rect.height/2);
const scale = Math.max(0.18, Math.min(0.32, targetRect.width / rect.width));
clone.style.transform = 'translate3d(' + tx + 'px,' + ty + 'px,0) scale(' + scale + ')';
}
clone.style.opacity = '0';
}, 60 + i*90);
setTimeout(()=>{ clone.remove(); checkDone(); }, 820);
});
// 未翻开的牌:延迟碎裂
if(unflipped.length){
setTimeout(()=>{
unflipped.forEach(card => { createShatter(card); card.style.opacity = '0'; });
setTimeout(checkDone, 720);
}, flipped.length ? 520 : 0);
}
}
function createShatter(card){
const N = 10;
for(let i=0;i<N;i++){
const s = document.createElement('div'); s.className = 'shard';
const angle = (Math.PI*2*i/N) + (Math.random()*0.7-0.35);
const dist = 55 + Math.random()*90;
s.style.setProperty('--tx', (Math.cos(angle)*dist).toFixed(1) + 'px');
s.style.setProperty('--ty', (Math.sin(angle)*dist).toFixed(1) + 'px');
s.style.setProperty('--rot', (Math.random()*360-180).toFixed(0) + 'deg');
card.appendChild(s);
}
card.classList.add('shatter');
}
(function bindTalentDraw(){
const ov = document.getElementById('talentDraw');
if(!ov) return;
const start = document.getElementById('tdStart');
const later = document.getElementById('tdLater');
const finish = document.getElementById('tdFinish');
if(start) start.addEventListener('click', tdStart);
if(later) later.addEventListener('click', () => { localStorage.setItem('talent_draw_done','1'); closeTalentDraw(); });
if(finish) finish.addEventListener('click', tdFinish);
ov.addEventListener('click', e => { if(e.target === ov) closeTalentDraw(); });
document.addEventListener('keydown', e => {
if(e.key === 'Escape'){ const o = document.getElementById('talentDraw'); if(o && o.classList.contains('open')) closeTalentDraw(); }
});
})();
// 登录后:新玩家(无任何天赋)自动弹出引导
async function maybeShowTalentDraw(){
if(localStorage.getItem('talent_draw_done')) return;
try{
const d = await API.charTalents();
if(d && d.ok){
const owned = (d.talents || []).filter(t => t.owned).length;
if(owned === 0) openTalentDraw();
}
}catch(e){}
}
</script>
<script src="assets/characters.js?v=20260825"></script>
<script> <script>
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 ===== // ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
(async () => { (async () => {
@@ -6809,10 +7221,30 @@ if (typeof fitWorld === 'function') fitWorld();
const trigger = document.getElementById('uaTrigger'); const trigger = document.getElementById('uaTrigger');
const menu = document.getElementById('uaMenu'); const menu = document.getElementById('uaMenu');
try { try {
// 剧情漫画:首次进入(未看过前两幕)先全屏播放,播完标记,再走登录判定
if (!localStorage.getItem('seen_intro_part1')) {
try {
await playIntroStory([
'assets/story/story_01_transmigration.jpg',
'assets/story/story_02_awakening.jpg'
], {
onStart: () => {
const loading = document.getElementById('loadingScreen');
if (loading) loading.style.display = 'none';
const authSplash = document.getElementById('authSplash');
if (authSplash) authSplash.style.display = 'none';
}
});
} catch (e) { console.warn('剧情播放异常', e); }
localStorage.setItem('seen_intro_part1', '1');
}
const me = await API.me(); const me = await API.me();
if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; } if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; }
document.getElementById('uaName').textContent = me.name || me.login; document.getElementById('uaName').textContent = me.name || me.login;
area.hidden = false; area.hidden = false;
const tbtn = document.getElementById('talentBtn');
if(tbtn) tbtn.hidden = false;
maybeShowTalentDraw();
// 拉取后端地表记忆(yt_world):确定性底图 + 玩家增量覆盖,叠加到当前 tiles // 拉取后端地表记忆(yt_world):确定性底图 + 玩家增量覆盖,叠加到当前 tiles
// 无论成功失败都更新世界名称,避免"加载中…"卡死 // 无论成功失败都更新世界名称,避免"加载中…"卡死
const wnEl = document.getElementById('worldName'); const wnEl = document.getElementById('worldName');
@@ -7180,7 +7612,7 @@ if (typeof fitWorld === 'function') fitWorld();
} }
})(); })();
</script> </script>
<script src="assets/world-app.js"></script> <script src="assets/world-app.js?v=20260825"></script>
<!-- ===== 世界聊天框(左下角,可折叠展开,demo) ===== --> <!-- ===== 世界聊天框(左下角,可折叠展开,demo) ===== -->
<style> <style>
@@ -7861,5 +8293,6 @@ if (typeof fitWorld === 'function') fitWorld();
loadMe(); loadMe();
})(); })();
</script> </script>
</body> </body>
</html> </html>