天赋系统
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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 '🌟'})
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
|
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
|||||||
|
@@ -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 });
|
||||||
|
|||||||
@@ -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;}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
Reference in New Issue
Block a user