494 lines
19 KiB
JavaScript
494 lines
19 KiB
JavaScript
// 宇森游戏 · 前端与 Odoo 通信层
|
||
// 本地调试:前端 8080/8888 -> Odoo 8069(跨域,带调试 CORS 头)
|
||
// 生产部署:前端与 Odoo 同域,由 nginx 代理 /game/*。
|
||
// 库名说明:本地 Odoo 用 odoo.conf 钉死 yt_game;线上游戏库名为 game(dbfilter 不含 yt_game)。
|
||
// game/* 走统一 API_CONFIG.db;yt_world/* 单独带 ?db=,按 WORLD_DB 区分本地/线上,避免 git 同步互相覆盖。
|
||
|
||
const _isLocalDev = () =>
|
||
location.hostname === '127.0.0.1' || location.hostname === 'localhost';
|
||
|
||
// 本地开发:统一走前端开发服务器(dev-server 默认 8080)的反向代理到 Odoo,
|
||
// 这样无论页面由 dev-server 同源提供,还是从本机其它端口(如预览窗)打开,
|
||
// API 都能经 8080 代理抵达 Odoo;_json 会回显 Origin 以支持跨域带 cookie。
|
||
// 线上(非 localhost)则同源走 nginx,base 为空。
|
||
const _isDevPort = (port) =>
|
||
location.port === String(port) ||
|
||
// 某些环境(如 Python http.server)可能不设 port(默认 80)或用空串
|
||
(port === 80 && (location.port === '' || location.port === '80'));
|
||
|
||
const API_CONFIG = {
|
||
base: _isLocalDev()
|
||
? ((_isDevPort(8080) || _isDevPort(8888)) ? '' : `http://${location.hostname}:8080`)
|
||
: '',
|
||
db: _isLocalDev() ? 'study' : 'game',
|
||
};
|
||
|
||
// 世界(yt_world)所在库:本地 Odoo 钉 yt_game;线上游戏库名为 game。按域名自适应,git 同步不冲突。
|
||
const WORLD_DB = _isLocalDev() ? 'yt_game' : 'game';
|
||
|
||
|
||
// Odoo 后端基础 URL(世界 API 专用,兼容本地跨域 / 生产同域)
|
||
const WORLD_BASE = API_CONFIG.base || '';
|
||
|
||
const _cred = API_CONFIG.base ? 'include' : 'same-origin';
|
||
const _wcred = WORLD_BASE ? 'include' : 'same-origin';
|
||
|
||
function _url(path) {
|
||
return `${API_CONFIG.base}${path}?db=${API_CONFIG.db}`;
|
||
}
|
||
|
||
function _worldUrl(path) {
|
||
return `${WORLD_BASE}${path}?db=${WORLD_DB}`;
|
||
}
|
||
|
||
const API = {
|
||
async me() {
|
||
return (await fetch(_url('/game/api/me'), { credentials: _cred })).json();
|
||
},
|
||
async login(login, password) {
|
||
return (await fetch(_url('/game/api/login'), {
|
||
method: 'POST', credentials: _cred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ login, password }),
|
||
})).json();
|
||
},
|
||
async register(payload) {
|
||
return (await fetch(_url('/game/api/register'), {
|
||
method: 'POST', credentials: _cred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})).json();
|
||
},
|
||
async logout() {
|
||
return (await fetch(_url('/game/api/logout'), {
|
||
method: 'POST', credentials: _cred,
|
||
})).json();
|
||
},
|
||
async achievements() {
|
||
return (await fetch(_url('/game/api/achievements'), { credentials: _cred })).json();
|
||
},
|
||
async mails() {
|
||
return (await fetch(_url('/game/api/mails'), { credentials: _cred })).json();
|
||
},
|
||
async mailRead(mailId) {
|
||
return (await fetch(_url('/game/api/mail/read'), {
|
||
method: 'POST', credentials: _cred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ mail_id: mailId }),
|
||
})).json();
|
||
},
|
||
async codex() {
|
||
return (await fetch(_url('/game/api/codex'), { credentials: _cred })).json();
|
||
},
|
||
async rules() {
|
||
return (await fetch(_url('/game/api/rules'), { credentials: _cred })).json();
|
||
},
|
||
async currencies() {
|
||
return (await fetch(_url('/game/api/currencies'), { credentials: _cred })).json();
|
||
},
|
||
async pages() {
|
||
return (await fetch(_url('/game/api/pages'), { credentials: _cred })).json();
|
||
},
|
||
async threads() {
|
||
return (await fetch(_url('/game/api/threads'), { credentials: _cred })).json();
|
||
},
|
||
async announcements() {
|
||
return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json();
|
||
},
|
||
async story() {
|
||
return (await fetch(_url('/game/api/story'), { credentials: _cred })).json();
|
||
},
|
||
async storyChapter(cid) {
|
||
return (await fetch(_url('/game/api/story/chapter/' + cid), { credentials: _cred })).json();
|
||
},
|
||
// ---------- 沙盘记忆(yt_world 模块) ----------
|
||
async world() {
|
||
return (await fetch(_worldUrl('/yt_world/api/world'), { credentials: _wcred })).json();
|
||
},
|
||
async saveTiles(tiles) {
|
||
return (await fetch(_worldUrl('/yt_world/api/tiles/save'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tiles }),
|
||
})).json();
|
||
},
|
||
async saveSettings(settings) {
|
||
return (await fetch(_worldUrl('/yt_world/api/settings'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ settings }),
|
||
})).json();
|
||
},
|
||
async renameWorld(name) {
|
||
return (await fetch(_worldUrl('/yt_world/api/world/rename'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
})).json();
|
||
},
|
||
// 微观世界命名(按地块坐标):命名后由后端持久化到 yt.world.tile.micro.name
|
||
async renameMicro(q, r, name) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/micro/name'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ q, r, name }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r0.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// 微观建造布置保存(按地块坐标)
|
||
// 返回结构化结果:{ok:true,...} 或 {ok:false, error:'auth'|'network', detail}
|
||
// —— auth='user' 路由未登录会 303 跳转到 /web/login,fetch 跟随后得到 HTML,
|
||
// .json() 会抛错;这里捕获并明确区分「未登录」与「网络不可达」。
|
||
async saveMicroBuild(q, r, items) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/micro/build/save'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ q, r, items }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r0.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// 建造扣费(落库前调用):扣成功才允许放置建筑。
|
||
// 返回 {ok:true, cost, gold} 或 {ok:false, error:'no_gold'|'auth'|'network', need, have}
|
||
async chargeBuild(code, count = 1) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/micro/build/charge'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ code, count }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r0.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// ---------- 世界数据 / 包裹(后端接口预留:yt_world 暂未实现路由,前端 mock 兜底) ----------
|
||
async worldData() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/data'), { credentials: _wcred });
|
||
if (!r.ok) return null;
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
async inventory() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/inventory'), { credentials: _wcred });
|
||
if (!r.ok) return null;
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
// ---------- 游戏化系统:角色 / 生物 / 订单 / 建筑交互 ----------
|
||
async char() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/char'), { credentials: _wcred });
|
||
if (!r.ok) return null;
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
async saveChar(patch) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/char/save'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch || {}),
|
||
});
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
async creatures() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/creatures'), { credentials: _wcred });
|
||
if (!r.ok) return null;
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
async saveCreatures(list) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ creatures: list || [] }),
|
||
});
|
||
return await r.json();
|
||
} catch (e) { return null; }
|
||
},
|
||
async races() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/races'), { credentials: _wcred });
|
||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||
} catch (e) { return { ok: false, error: 'network' }; }
|
||
},
|
||
async equipCatalog() {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/equip/catalog'), { credentials: _wcred });
|
||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||
} catch (e) { return { ok: false, error: 'network' }; }
|
||
},
|
||
async creature(cid) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/creature/' + encodeURIComponent(cid)), { credentials: _wcred });
|
||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||
} catch (e) { return { ok: false, error: 'network' }; }
|
||
},
|
||
async creatureAction(payload) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/creature/action'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload || {}),
|
||
});
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) { return { ok: false, error: 'network' }; }
|
||
},
|
||
async buildingAction(payload) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/building/action'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload || {}),
|
||
});
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ----------
|
||
// 以下两个已重定向到世界生物表(yt.world.creature),保留旧名以兼容 characters.js
|
||
async microChars() {
|
||
return this.creatures();
|
||
},
|
||
async saveMicroChars(chars, deleted) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ creatures: chars || [], deleted: deleted || [] }),
|
||
});
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// 以下旧名重定向到世界生物表(yt.world.creature),保留以兼容历史调用
|
||
async microEquipCatalog() { return this.equipCatalog(); },
|
||
async microChar(cid) { return this.creature(cid); },
|
||
async microCharAction(payload) { return this.creatureAction(payload); },
|
||
// ---------- 图鉴(codex) ----------
|
||
async codexResources() {
|
||
return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json();
|
||
},
|
||
async codexCreatures() {
|
||
return (await fetch(_worldUrl('/yt_world/api/codex/creatures'), { credentials: _wcred })).json();
|
||
},
|
||
async codexMonsters() {
|
||
return (await fetch(_worldUrl('/yt_world/api/codex/monsters'), { credentials: _wcred })).json();
|
||
},
|
||
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 });
|
||
if (!r.ok) return { ok: false, error: 'http_' + r.status };
|
||
return await r.json();
|
||
},
|
||
async taskComplete(code) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/task/complete'), {
|
||
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' };
|
||
}
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// 发射游戏事件,推进任务进度(服务端事件总线)
|
||
async taskProgress(event, payload) {
|
||
try {
|
||
const r = await fetch(_worldUrl('/yt_world/api/task/progress'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ event, payload: payload || {} }),
|
||
});
|
||
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r.json();
|
||
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
// 暴露后端 base,供图片等静态资源 URL 拼接(本地跨域→8069;线上同域→'')
|
||
base: API_CONFIG.base,
|
||
|
||
// ---------- 资源系统(库存 / 结算 / 离线囤积回收) ----------
|
||
async worldResources(q, r) {
|
||
try {
|
||
const qs = (q !== undefined && r !== undefined) ? ('?q=' + q + '&r=' + r) : '';
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/world/resources') + qs, { credentials: _wcred });
|
||
if (!r0.ok) return { ok: false, error: 'http_' + r0.status };
|
||
return await r0.json();
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
async worldSettle(auto_collect = false, force_collect = false) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/world/settle'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ auto_collect, force_collect }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
return await r0.json();
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
async collectBuilding(q, r, i, j) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/building/collect'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ q, r, i, j }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
return await r0.json();
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
|
||
// ---------- 宝箱系统 ----------
|
||
async chests(q, r) {
|
||
try {
|
||
const qs = (q !== undefined && r !== undefined) ? ('&q=' + q + '&r=' + r) : '';
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/chests') + qs, { credentials: _wcred });
|
||
if (!r0.ok) return { ok: false, error: 'http_' + r0.status };
|
||
return await r0.json();
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
async chestGenerate(q, r, grid_n = 20) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/chest/generate'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ q, r, grid_n: grid_n }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r0.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
async chestClaim(chest_id, cid) {
|
||
try {
|
||
const r0 = await fetch(_worldUrl('/yt_world/api/chest/claim'), {
|
||
method: 'POST', credentials: _wcred,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ chest_id, cid }),
|
||
});
|
||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||
return { ok: false, error: 'auth' };
|
||
}
|
||
const j = await r0.json();
|
||
return j && j.ok === false ? j : (j || { ok: true });
|
||
} catch (e) {
|
||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||
}
|
||
},
|
||
};
|