Merge pull request '天赋系统' (#7) from why_ga into main

Reviewed-on: #7
This commit is contained in:
李鹏宇
2026-08-28 10:28:22 +08:00
18 changed files with 9046 additions and 7915 deletions
+1
View File
@@ -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',
+241 -1
View File
@@ -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 '🌟'})
# ============================================================
# 游戏化系统:角色 / 生物 / 订单 / 建筑交互(前端-后端打通)
# ============================================================
+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="talent_ancient_blood" model="yt.codex.talent">
<field name="code">talent_ancient_blood</field>
<field name="name">先民血脉</field>
<field name="icon">🩸</field>
<field name="quality">mythic</field>
<field name="trigger">被动</field>
<field name="effect">生命回复+20%;濒死时触发先民护盾,5秒内免伤</field>
<field name="description">流淌着远古先民的血脉,身体拥有超乎寻常的自愈与守护之力。传说中先民以此血脉抵御了灭世之灾。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">10</field>
</record>
<record id="talent_dragon_soul" model="yt.codex.talent">
<field name="code">talent_dragon_soul</field>
<field name="name">龙裔之魂</field>
<field name="icon">🐉</field>
<field name="quality">mythic</field>
<field name="trigger">被动</field>
<field name="effect">火焰抗性+50%;攻击附带灼烧效果</field>
<field name="description">体内沉睡着巨龙的灵魂碎片,对火焰有着天然的亲和与免疫,攻击时能唤出龙息灼烧敌人。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">20</field>
</record>
<record id="talent_star_resonance" model="yt.codex.talent">
<field name="code">talent_star_resonance</field>
<field name="name">星辰共鸣</field>
<field name="icon">✨</field>
<field name="quality">legendary</field>
<field name="trigger">被动</field>
<field name="effect">法力回复+15%;夜间额外+10%</field>
<field name="description">与星辰之力产生共鸣,能够汲取星光转化为法力。夜幕降临时,这份共鸣尤为强烈。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">30</field>
</record>
<record id="talent_beast_king" model="yt.codex.talent">
<field name="code">talent_beast_king</field>
<field name="name">兽王本能</field>
<field name="icon">🐾</field>
<field name="quality">epic</field>
<field name="trigger">被动</field>
<field name="effect">驯养生物好感度+30%;生物产出+20%</field>
<field name="description">天生与野兽亲和,能感知它们的情绪与需求。驯养的生物更加忠诚,产出也更加丰厚。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">40</field>
</record>
<record id="talent_smith_hand" model="yt.codex.talent">
<field name="code">talent_smith_hand</field>
<field name="name">铁匠之手</field>
<field name="icon">🔨</field>
<field name="quality">rare</field>
<field name="trigger">被动</field>
<field name="effect">加工品品质+1阶;加工成功率+10%</field>
<field name="description">双手有着与生俱来的锻造天赋,加工材料时更加精准,成品的品质也往往超出预期。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">50</field>
</record>
<record id="talent_forest_walker" model="yt.codex.talent">
<field name="code">talent_forest_walker</field>
<field name="name">林间行者</field>
<field name="icon">🌿</field>
<field name="quality">rare</field>
<field name="trigger">被动</field>
<field name="effect">森林地形移速+25%;隐蔽+15%</field>
<field name="description">在密林中如鱼得水,脚步轻盈无声,仿佛与森林融为一体。敌人很难在林间发现你的踪迹。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">60</field>
</record>
<record id="talent_ore_sense" model="yt.codex.talent">
<field name="code">talent_ore_sense</field>
<field name="name">矿脉感应</field>
<field name="icon">⛏️</field>
<field name="quality">uncommon</field>
<field name="trigger">被动</field>
<field name="effect">发现稀有矿脉概率+20%</field>
<field name="description">对地底的矿脉有着敏锐的直觉,总能在别人忽略的地方发现珍贵的矿石。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">70</field>
</record>
<record id="talent_harvest_blessing" model="yt.codex.talent">
<field name="code">talent_harvest_blessing</field>
<field name="name">丰收祝福</field>
<field name="icon">🌾</field>
<field name="quality">uncommon</field>
<field name="trigger">被动</field>
<field name="effect">农作物产量+15%;生长速度+10%</field>
<field name="description">受到大地之灵的祝福,经手种植的作物总是长势喜人,收获时也比旁人更加丰盈。</field>
<field name="max_level">5</field>
<field name="cost_type">point</field>
<field name="cost_value">1</field>
<field name="sequence">80</field>
</record>
</odoo>
+2
View File
@@ -7,6 +7,8 @@ from . import yt_world_game
from . import yt_world_task
from . import yt_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
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class YtCodexTalent(models.Model):
"""图鉴-天赋(全局配置)。每个玩家在 yt.world.character.talent 中拥有
自己的激活记录与等级,但配置本身全局共享。"""
_name = 'yt.codex.talent'
_description = '图鉴-天赋'
_order = 'sequence, id'
name = fields.Char(string='名称', required=True, translate=True)
code = fields.Char(string='编码', required=True, index=True)
icon = fields.Char(string='图标', default='🌟')
quality = fields.Selection([
('common', '普通'),
('uncommon', '优秀'),
('rare', '精良'),
('epic', '史诗'),
('legendary', '传说'),
('mythic', '神话'),
], string='品质', default='common', required=True)
trigger = fields.Char(string='触发方式', default='被动')
effect = fields.Char(string='效果')
description = fields.Char(string='描述')
max_level = fields.Integer(string='最大等级', default=1)
cost_type = fields.Selection([
('point', '天赋点'),
('gold', '金币'),
('exp', '经验'),
('item', '道具'),
], string='升级消耗类型', default='point')
cost_value = fields.Integer(string='每级消耗', default=0)
unlock_condition = fields.Char(string='解锁条件')
prerequisite_talent_id = fields.Many2one(
'yt.codex.talent', string='前置天赋', ondelete='set null',
help='激活本天赋前必须先激活的天赋'
)
sequence = fields.Integer(string='排序', default=10)
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class YtWorldCharacterTalent(models.Model):
"""玩家角色已拥有的天赋记录。绑定 yt.world.character,每个角色独立。"""
_name = 'yt.world.character.talent'
_description = '玩家角色天赋'
_order = 'character_id, talent_id'
character_id = fields.Many2one(
'yt.world.character', string='角色', required=True,
ondelete='cascade', index=True
)
talent_id = fields.Many2one(
'yt.codex.talent', string='天赋', required=True, index=True
)
level = fields.Integer(string='等级', default=1)
active = fields.Boolean(string='已激活', default=True)
acquired_at = fields.Datetime(string='获得时间', default=fields.Datetime.now)
_sql_constraints = [
('uniq_char_talent', 'unique(character_id, talent_id)', '角色不能重复拥有同一天赋'),
]
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('level', 1) < 1:
vals['level'] = 1
return super().create(vals_list)
def can_upgrade(self):
"""检查是否还能升级:未达最大等级、角色有足够天赋点。"""
self.ensure_one()
char = self.character_id
talent = self.talent_id
if self.level >= (talent.max_level or 1):
return False, 'max_level'
cost = talent.cost_value or 0
if talent.cost_type == 'point':
if (char.talent_points or 0) < cost:
return False, 'no_points'
elif talent.cost_type == 'gold':
if (char.gold or 0) < cost:
return False, 'no_gold'
return True, None
def do_upgrade(self):
"""执行升级:扣资源、等级+1。调用前须先用 can_upgrade 校验。"""
self.ensure_one()
ok, reason = self.can_upgrade()
if not ok:
return {'ok': False, 'error': reason}
talent = self.talent_id
cost = talent.cost_value or 0
char = self.character_id
if talent.cost_type == 'point':
char.talent_points = (char.talent_points or 0) - cost
elif talent.cost_type == 'gold':
char.gold = (char.gold or 0) - cost
self.level += 1
return {'ok': True, 'level': self.level}
+5
View File
@@ -36,10 +36,15 @@ class YtWorldCharacter(models.Model):
exp = fields.Integer(string='经验', default=0)
exp_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)
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yt_codex_talent_user yt.codex.talent user model_yt_codex_talent base.group_user 1 0 0 0
3 access_yt_codex_talent_public yt.codex.talent public model_yt_codex_talent base.group_public 1 0 0 0
4 access_yt_codex_talent_portal yt.codex.talent portal model_yt_codex_talent base.group_portal 1 0 0 0
5 access_yt_world_character_talent_user yt.world.character.talent user model_yt_world_character_talent base.group_user 1 1 1 1
6 access_yt_world_character_talent_public yt.world.character.talent public model_yt_world_character_talent base.group_public 1 0 0 0
7 access_yt_world_character_talent_portal yt.world.character.talent portal model_yt_world_character_talent base.group_portal 1 1 1 1
8 access_yt_world_user yt.world user model_yt_world base.group_user 1 1 1 1
9 access_yt_world_public yt.world public model_yt_world base.group_public 1 1 1 1
10 access_yt_world_portal yt.world portal model_yt_world base.group_portal 1 1 1 1
+49
View File
@@ -280,6 +280,55 @@ const API = {
async codexRaces() {
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 });
+13
View File
@@ -0,0 +1,13 @@
/* 宇森剧情漫画播放器 —— 全屏遮罩样式 */
#introOverlay{position:fixed;inset:0;z-index:6000;background:#05070d;display:none;align-items:center;justify-content:center;flex-direction:column;opacity:0;transition:opacity .5s ease;user-select:none;}
#introOverlay.active{display:flex;opacity:1;}
#introOverlay .introImg{max-width:92vw;max-height:78vh;border-radius:12px;box-shadow:0 20px 70px rgba(0,0,0,.7);opacity:0;transform:scale(.98);transition:opacity .45s ease,transform .45s ease;pointer-events:none;}
#introOverlay .introImg.show{opacity:1;transform:scale(1);}
#introOverlay .introDots{display:flex;gap:10px;margin-top:22px;}
#introOverlay .introDot{width:9px;height:9px;border-radius:50%;background:rgba(255,255,255,.25);transition:background .3s,transform .3s;}
#introOverlay .introDot.on{background:#4af;transform:scale(1.25);}
#introOverlay .introHint{position:absolute;bottom:26px;left:0;right:0;text-align:center;color:#9fb0c0;font-size:13px;letter-spacing:.5px;transition:opacity .3s;}
#introOverlay .introSkip{position:absolute;top:18px;right:20px;padding:7px 16px;border-radius:20px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.18);color:#cdd8e6;font-size:13px;cursor:pointer;transition:background .2s;}
#introOverlay .introSkip:hover{background:rgba(255,255,255,.18);}
#introOverlay .introEnter{margin-top:26px;padding:12px 38px;border:none;border-radius:26px;background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;font-size:15px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px rgba(74,175,255,.4);opacity:0;transform:translateY(10px);pointer-events:none;transition:opacity .4s,transform .4s;}
#introOverlay .introEnter.show{opacity:1;transform:translateY(0);pointer-events:auto;}
+106
View File
@@ -0,0 +1,106 @@
/**
* 宇森剧情漫画播放器
* 用法:
* await playIntroStory(['assets/story/story_01.jpg', 'assets/story/story_02.jpg']);
* 或:
* await playIntroStory(['assets/story/story_03.jpg'], { enterText: '进入世界 ▸', holdMs: 5000 });
*/
// 确保遮罩 DOM 存在(页面只需引入本脚本与 CSS)
(function ensureOverlay() {
if (document.getElementById('introOverlay')) return;
const overlay = document.createElement('div');
overlay.id = 'introOverlay';
overlay.innerHTML =
'<div class="introSkip" id="introSkip">跳过剧情 ▸</div>' +
'<img class="introImg" id="introImg" alt="剧情" />' +
'<button class="introEnter" id="introEnter">踏入宇森世界 ▸</button>' +
'<div class="introDots" id="introDots"></div>' +
'<div class="introHint" id="introHint">点击 / 空格 / → 继续</div>';
document.body.appendChild(overlay);
})();
window.playIntroStory = function playIntroStory(panels, options) {
options = options || {};
const holdMs = options.holdMs || 5000;
const enterText = options.enterText || '踏入宇森世界 ▸';
const onStart = options.onStart || null; // 开始播放前回调,用于隐藏页面原生元素
return new Promise((resolve) => {
const overlay = document.getElementById('introOverlay');
const img = document.getElementById('introImg');
const dotsWrap = document.getElementById('introDots');
const hint = document.getElementById('introHint');
const skipBtn = document.getElementById('introSkip');
const enterBtn = document.getElementById('introEnter');
if (!Array.isArray(panels) || panels.length === 0) {
resolve();
return;
}
if (typeof onStart === 'function') onStart();
enterBtn.textContent = enterText;
let idx = 0, autoTimer = null, done = false, dots = [];
dotsWrap.innerHTML = '';
panels.forEach((_, i) => {
const d = document.createElement('div');
d.className = 'introDot' + (i === 0 ? ' on' : '');
dotsWrap.appendChild(d);
});
dots = dotsWrap.querySelectorAll('.introDot');
function showPanel(i) {
idx = i;
dots.forEach((d, k) => d.classList.toggle('on', k === i));
img.classList.remove('show');
img.onload = () => img.classList.add('show');
img.src = panels[i];
if (img.complete) img.classList.add('show');
const last = (i === panels.length - 1);
enterBtn.classList.toggle('show', last);
hint.style.opacity = last ? '0' : '1';
clearTimeout(autoTimer);
if (!last) autoTimer = setTimeout(nextPanel, holdMs);
}
function nextPanel() {
if (done || idx >= panels.length - 1) return;
showPanel(idx + 1);
}
function finish() {
if (done) return;
done = true;
clearTimeout(autoTimer);
document.removeEventListener('keydown', onKey);
overlay.classList.remove('active');
setTimeout(() => { overlay.style.display = 'none'; }, 500);
resolve();
}
function onKey(e) {
if (!overlay.classList.contains('active')) return;
if (e.key === ' ' || e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (idx === panels.length - 1) finish(); else nextPanel();
} else if (e.key === 'Escape') {
finish();
}
}
overlay.addEventListener('click', (e) => {
if (e.target === skipBtn || e.target === enterBtn) return;
nextPanel();
});
skipBtn.addEventListener('click', finish);
enterBtn.addEventListener('click', finish);
document.addEventListener('keydown', onKey);
overlay.style.display = 'flex';
requestAnimationFrame(() => overlay.classList.add('active'));
showPanel(0);
});
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

+76 -31
View File
@@ -234,6 +234,15 @@ body::before{
.md-chips{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px;}
.md-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>
</head>
<body>
@@ -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 = '<div class="empty show">正在从服务器加载图鉴数据…</div>';
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 ? `<span class="tag" style="background:rgba(139,92,246,.25);color:#d0bcff;">Lv ${e.level}</span>` : '';
div.innerHTML = `
<span class="qbadge ${e.qual}">${QUALITY_NAMES[e.qual]}</span>
<div class="icon">${e.icon}</div>
<div class="name">${e.name}</div>
<div class="tags">
<span class="tag">${e.trigger||'被动'}</span>
${mine}
</div>
`;
} 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="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">${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>`;
if(e.effect){
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>';
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;
}
});
}
}
// ===== 键盘关闭 =====
+21
View File
@@ -88,6 +88,7 @@
.hero .enter-btn{font-size:15px;padding:11px 30px;}
}
</style>
<link rel="stylesheet" href="assets/story-player.css" />
</head>
<body>
<div class="app">
@@ -247,5 +248,25 @@ document.getElementById('enterGame').addEventListener('click', async ()=>{
}
});
</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>
</html>
+13 -1
View File
@@ -71,6 +71,7 @@
.submit{padding:13px;font-size:15px;}
}
</style>
<link rel="stylesheet" href="assets/story-player.css" />
</head>
<body>
<div class="bg-slides"></div>
@@ -234,7 +235,17 @@
const btn = regForm.querySelector('.submit'); btn.disabled = true; btn.textContent = '注册中…';
try {
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'); }
} catch (err) {
showMsg('网络错误,请稍后重试', 'err');
@@ -243,5 +254,6 @@
}
});
</script>
<script src="assets/story-player.js"></script>
</body>
</html>
+8297 -7882
View File
File diff suppressed because it is too large Load Diff