diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py
index 14a1d3e..1813702 100644
--- a/addons/yt_world/__manifest__.py
+++ b/addons/yt_world/__manifest__.py
@@ -11,6 +11,7 @@
'data/codex_creature.xml',
'data/codex_monster.xml',
'data/codex_race.xml',
+ 'data/codex_talent.xml',
'data/yt_world_build_item.xml',
'data/yt_world_task.xml',
'views/yt_world_views.xml',
diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py
index e6a01ba..57037d4 100644
--- a/addons/yt_world/controllers/world_api.py
+++ b/addons/yt_world/controllers/world_api.py
@@ -2,7 +2,8 @@
import json
import logging
import re
-from odoo import http
+import random
+from odoo import http, fields
from odoo.http import request, Response
_logger = logging.getLogger(__name__)
@@ -297,6 +298,245 @@ class YtWorldApi(http.Controller):
return self._records_json('yt.codex.race',
['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 '🌟'})
+
# ============================================================
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
# ============================================================
diff --git a/addons/yt_world/data/codex_talent.xml b/addons/yt_world/data/codex_talent.xml
new file mode 100644
index 0000000..4cedddf
--- /dev/null
+++ b/addons/yt_world/data/codex_talent.xml
@@ -0,0 +1,114 @@
+
+
+
+ talent_ancient_blood
+ 先民血脉
+ 🩸
+ mythic
+ 被动
+ 生命回复+20%;濒死时触发先民护盾,5秒内免伤
+ 流淌着远古先民的血脉,身体拥有超乎寻常的自愈与守护之力。传说中先民以此血脉抵御了灭世之灾。
+ 5
+ point
+ 1
+ 10
+
+
+
+ talent_dragon_soul
+ 龙裔之魂
+ 🐉
+ mythic
+ 被动
+ 火焰抗性+50%;攻击附带灼烧效果
+ 体内沉睡着巨龙的灵魂碎片,对火焰有着天然的亲和与免疫,攻击时能唤出龙息灼烧敌人。
+ 5
+ point
+ 1
+ 20
+
+
+
+ talent_star_resonance
+ 星辰共鸣
+ ✨
+ legendary
+ 被动
+ 法力回复+15%;夜间额外+10%
+ 与星辰之力产生共鸣,能够汲取星光转化为法力。夜幕降临时,这份共鸣尤为强烈。
+ 5
+ point
+ 1
+ 30
+
+
+
+ talent_beast_king
+ 兽王本能
+ 🐾
+ epic
+ 被动
+ 驯养生物好感度+30%;生物产出+20%
+ 天生与野兽亲和,能感知它们的情绪与需求。驯养的生物更加忠诚,产出也更加丰厚。
+ 5
+ point
+ 1
+ 40
+
+
+
+ talent_smith_hand
+ 铁匠之手
+ 🔨
+ rare
+ 被动
+ 加工品品质+1阶;加工成功率+10%
+ 双手有着与生俱来的锻造天赋,加工材料时更加精准,成品的品质也往往超出预期。
+ 5
+ point
+ 1
+ 50
+
+
+
+ talent_forest_walker
+ 林间行者
+ 🌿
+ rare
+ 被动
+ 森林地形移速+25%;隐蔽+15%
+ 在密林中如鱼得水,脚步轻盈无声,仿佛与森林融为一体。敌人很难在林间发现你的踪迹。
+ 5
+ point
+ 1
+ 60
+
+
+
+ talent_ore_sense
+ 矿脉感应
+ ⛏️
+ uncommon
+ 被动
+ 发现稀有矿脉概率+20%
+ 对地底的矿脉有着敏锐的直觉,总能在别人忽略的地方发现珍贵的矿石。
+ 5
+ point
+ 1
+ 70
+
+
+
+ talent_harvest_blessing
+ 丰收祝福
+ 🌾
+ uncommon
+ 被动
+ 农作物产量+15%;生长速度+10%
+ 受到大地之灵的祝福,经手种植的作物总是长势喜人,收获时也比旁人更加丰盈。
+ 5
+ point
+ 1
+ 80
+
+
diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py
index 768baee..7916d3f 100644
--- a/addons/yt_world/models/__init__.py
+++ b/addons/yt_world/models/__init__.py
@@ -7,6 +7,8 @@ from . import yt_world_game
from . import yt_world_task
from . import yt_resource
from . import yt_codex
+from . import yt_codex_talent
+from . import yt_world_character_talent
from . import npc
from . import npc_relationship
from . import npc_behavior
diff --git a/addons/yt_world/models/yt_codex_talent.py b/addons/yt_world/models/yt_codex_talent.py
new file mode 100644
index 0000000..b3d1ad5
--- /dev/null
+++ b/addons/yt_world/models/yt_codex_talent.py
@@ -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)
diff --git a/addons/yt_world/models/yt_world_character_talent.py b/addons/yt_world/models/yt_world_character_talent.py
new file mode 100644
index 0000000..d4d78b7
--- /dev/null
+++ b/addons/yt_world/models/yt_world_character_talent.py
@@ -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}
diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py
index 873756a..737986d 100644
--- a/addons/yt_world/models/yt_world_game.py
+++ b/addons/yt_world/models/yt_world_game.py
@@ -36,10 +36,15 @@ class YtWorldCharacter(models.Model):
exp = fields.Integer(string='经验', default=0)
exp_max = 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_display = fields.Text(string='背包(可读)', compute='_compute_displays')
equip = fields.Json(string='装备', default=dict)
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}]
# state ∈ in_progress / claimable / done(locked 为前端按前置派生,不落库)
tasks = fields.Json(string='任务进度', default=list)
diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv
index d431109..77e1a92 100644
--- a/addons/yt_world/security/ir.model.access.csv
+++ b/addons/yt_world/security/ir.model.access.csv
@@ -1,4 +1,10 @@
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_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
diff --git a/assets/game-api.js b/assets/game-api.js
index 22ed2eb..58e8669 100644
--- a/assets/game-api.js
+++ b/assets/game-api.js
@@ -280,6 +280,55 @@ const API = {
async codexRaces() {
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() {
const r = await fetch(_worldUrl('/yt_world/api/tasks'), { credentials: _wcred });
diff --git a/assets/story-player.css b/assets/story-player.css
new file mode 100644
index 0000000..a627f56
--- /dev/null
+++ b/assets/story-player.css
@@ -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;}
diff --git a/assets/story-player.js b/assets/story-player.js
new file mode 100644
index 0000000..610a888
--- /dev/null
+++ b/assets/story-player.js
@@ -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 =
+ '
跳过剧情 ▸
' +
+ '
' +
+ '' +
+ '' +
+ '点击 / 空格 / → 继续
';
+ 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);
+ });
+};
diff --git a/assets/story/story_01_transmigration.jpg b/assets/story/story_01_transmigration.jpg
new file mode 100644
index 0000000..adedfd6
Binary files /dev/null and b/assets/story/story_01_transmigration.jpg differ
diff --git a/assets/story/story_02_awakening.jpg b/assets/story/story_02_awakening.jpg
new file mode 100644
index 0000000..de3e8ad
Binary files /dev/null and b/assets/story/story_02_awakening.jpg differ
diff --git a/assets/story/story_03_registration.jpg b/assets/story/story_03_registration.jpg
new file mode 100644
index 0000000..1801294
Binary files /dev/null and b/assets/story/story_03_registration.jpg differ
diff --git a/codex.html b/codex.html
index 246c174..40ca4b0 100644
--- a/codex.html
+++ b/codex.html
@@ -234,6 +234,15 @@ body::before{
.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-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;}
+
@@ -324,33 +333,8 @@ const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:
const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' };
const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','special'];
-// ===== 天赋预置数据(前端硬编码,后端就绪后迁移) =====
-const TALENT_DATA = [
- { 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:'受到大地之灵的祝福,经手种植的作物总是长势喜人,收获时也比旁人更加丰盈。' },
-];
+// 当前玩家已拥有天赋(从后端加载){ code: { level, active } }
+let MY_TALENTS = {};
// ===== 技能书预置数据(前端硬编码,后端就绪后迁移) =====
const SKILL_BOOK_DATA = [
@@ -403,10 +387,14 @@ const BOOK_DATA = [
];
function normTalent(t){
+ const mine = MY_TALENTS[t.code];
return {
type:'talent', id:t.code, cat:'talent', name:t.name, icon:t.icon||'🌟',
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){
@@ -497,15 +485,25 @@ async function loadCodexData(){
const grid = document.getElementById('grid');
grid.innerHTML = '正在从服务器加载图鉴数据…
';
try{
- const [res, cre, mon, rac] = await Promise.all([
- API.codexResources(), API.codexCreatures(), API.codexMonsters(), API.codexRaces()
+ const [res, cre, mon, rac, talents] = await Promise.all([
+ 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 = [
...(res||[]).map(normResource),
...(cre||[]).map(normCreature),
...(mon||[]).map(normMonster),
...(rac||[]).map(normRace),
- ...TALENT_DATA.map(normTalent),
+ ...(talents||[]).map(normTalent),
...SKILL_BOOK_DATA.map(normSkillBook),
...BOOK_DATA.map(normBook),
];
@@ -590,12 +588,14 @@ function renderGrid(){
`;
} else if(e.type==='talent'){
div.classList.add('q-'+e.qual, 'talent');
+ const mine = e.owned ? `Lv ${e.level}` : '';
div.innerHTML = `
${QUALITY_NAMES[e.qual]}
${e.icon}
${e.name}
${e.trigger||'被动'}
+ ${mine}
`;
} else if(e.type==='skill_book'){
@@ -736,10 +736,23 @@ function showDetail(e){
html += `📋 属性
`;
html += `
触发方式${e.trigger||'被动'}
`;
html += `
品质${QUALITY_NAMES[e.qual]||'—'}
`;
+ html += `
当前等级${e.owned ? 'Lv ' + e.level : '未激活'}
`;
+ html += `
最大等级Lv ${e.maxLevel||1}
`;
+ if(e.costValue){
+ const costLabel = e.costType === 'gold' ? '金币' : '天赋点';
+ const costTitle = e.owned ? '升级消耗' : '激活消耗';
+ html += `
${costTitle}${e.costValue} ${costLabel}
`;
+ }
html += `
`;
if(e.effect){
html += ``;
}
+ const canUpgrade = e.owned && e.level < e.maxLevel;
+ if(!e.owned){
+ html += ``;
+ } else if(canUpgrade){
+ html += ``;
+ }
}
// 技能书详情
@@ -767,6 +780,38 @@ function showDetail(e){
html += '';
box.innerHTML = html;
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;
+ }
+ });
+ }
}
// ===== 键盘关闭 =====
diff --git a/index.html b/index.html
index 63fce74..f7f00c4 100644
--- a/index.html
+++ b/index.html
@@ -88,6 +88,7 @@
.hero .enter-btn{font-size:15px;padding:11px 30px;}
}
+
@@ -247,5 +248,25 @@ document.getElementById('enterGame').addEventListener('click', async ()=>{
}
});
+
+