feedback5: merge W5M — the nine 0.1.4 PRs (#150 #66 #146 #74 #72 #103 #149 #109 #160) and W4A (高台 per the trait, 阿戈尔 revive slots, 联防 devour) ported into the split layout
Conflicts: server/match/bot.js (W4A's comment + W5K's player data view pgd), test/golden/diy.json (regenerated). data/ equals an offline build of the merged tools/build-data.mjs. Golden regenerated on the merged code: against feedback5 the moved roster / bonds / fields / matches / standins scenarios are exactly W5M's per-PR attribution (#160 skill events, #109 deploy-timed lifecycle, #66 灵巧 sources and hidden layers, 83fc408 歌蕾蒂娅 placement), plus coop2-NORMAL-70-diy (fields operators those PRs touch) and diy-051 / diy-052 (events.skill only: Misery's deploy passives, #160); against fb5-merge4 only feedback5's own additions (the diy kits, coop2-NORMAL-70-diy).
This commit is contained in:
commit
9ce5435373
74 files changed
+2966
-1338
No files matched your search
+43
-7
@@ -1,7 +1,8 @@
|
||||
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
|
||||
//
|
||||
// Sources: data/assets.json → audio (docs/ASSETS.md):
|
||||
// bgm { lobby, prep, combat, boss: { intro?, loop } }, bossBgm { [bossId]: { intro?, loop } },
|
||||
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
|
||||
// bossBgm { [bossId]: { intro?, loop } },
|
||||
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
|
||||
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
|
||||
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
|
||||
@@ -11,6 +12,10 @@
|
||||
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
|
||||
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
|
||||
// keeps playing across phases (prep and combat share a track).
|
||||
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
|
||||
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
|
||||
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
|
||||
// 联防 that follows a 作战 keeps its round's track.
|
||||
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
|
||||
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
|
||||
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
|
||||
@@ -59,9 +64,11 @@ const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
||||
* BGM key for a route + match phase.
|
||||
* @param {'title'|'lobby'|'room'|'game'|string} route
|
||||
* @param {any} pub m.public (may be null)
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'unite' | 'boss' | 'boss:<bossId>' | null
|
||||
* @param {0|1|null} [combatTrack] the round's own 开战 track index into `bgm.combatAlts` (combatTrackFor; omitted ⇒
|
||||
* plain 'combat', i.e. the manifest's default combat track)
|
||||
* @returns {string|null} 'lobby' | 'prep' | 'combat' | 'combat:<i>' | 'unite' | 'boss' | 'boss:<bossId>' | null
|
||||
*/
|
||||
export function bgmKeyFor(route, pub) {
|
||||
export function bgmKeyFor(route, pub, combatTrack = null) {
|
||||
if (route !== 'game') return route === 'title' || route === 'lobby' || route === 'room' ? 'lobby' : null;
|
||||
const phase = pub?.phase;
|
||||
if (!phase) return 'lobby';
|
||||
@@ -74,7 +81,8 @@ export function bgmKeyFor(route, pub) {
|
||||
// resolveBgm falls back to `bgm.combat` when a manifest predates it.
|
||||
return 'unite';
|
||||
case PHASE.COMBAT:
|
||||
return 'combat';
|
||||
// 开战 BGM: the round's own track — 骑士之日 / 无畏者 are fixed per round, not drawn (combatTrackFor)
|
||||
return combatTrack == null ? 'combat' : `combat:${combatTrack ? 1 : 0}`;
|
||||
case PHASE.FINAL_ASSAULT:
|
||||
return pub.bossId ? `boss:${pub.bossId}` : 'boss';
|
||||
case PHASE.HIDDEN_CORE:
|
||||
@@ -85,8 +93,9 @@ export function bgmKeyFor(route, pub) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest: `boss:<id>` falls back to the generic boss track and
|
||||
* `unite` (联防's own track) to `bgm.combat` when the manifest predates it.
|
||||
* Resolve a BGM key to { intro?, loop } URLs from the manifest: `boss:<id>` falls back to the generic boss track,
|
||||
* `combat:<i>` to the i-th `bgm.combatAlts` entry (and to `bgm.combat` when the manifest has none), and `unite`
|
||||
* (联防's own track) to `bgm.combat` when the manifest predates it.
|
||||
* @param {any} manifest
|
||||
* @param {string|null} key
|
||||
* @returns {{ intro: string|null, loop: string }|null}
|
||||
@@ -96,12 +105,33 @@ export function resolveBgm(manifest, key) {
|
||||
if (!a || !key) return null;
|
||||
let t = null;
|
||||
if (key.startsWith('boss:')) t = a.bossBgm?.[key.slice(5)] || a.bgm?.boss;
|
||||
else if (key.startsWith('combat:')) t = a.bgm?.combatAlts?.[Number(key.slice('combat:'.length))] || a.bgm?.combat;
|
||||
else if (key === 'unite') t = a.bgm?.unite || a.bgm?.combat;
|
||||
else t = a.bgm?.[key];
|
||||
if (!t || typeof t.loop !== 'string') return null;
|
||||
return { intro: typeof t.intro === 'string' ? t.intro : null, loop: t.loop };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last round that plays 无畏者 (1–7); from the next round on it is 骑士之日 (8–13) — the official schedule
|
||||
* (docs/ASSETS.md "BGM"; the two tracks are the 塞壬唱片 act13side battle themes).
|
||||
*/
|
||||
export const COMBAT_TRACK_SWITCH_ROUND = 7;
|
||||
|
||||
/**
|
||||
* The round's own 开战 track index into `bgm.combatAlts` (plan.mjs order: 0 = `m_bat_kazimierz2_1` 骑士之日,
|
||||
* 1 = `m_bat_kazimierz2_2` 无畏者). The mode does not draw these: the official schedule plays one per round, 无畏者
|
||||
* through the early rounds (1–7) and 骑士之日 from round 8 to the last normal round (8–13). Everything after that is
|
||||
* the boss rounds (最终攻势 / 隐秘核心), which have their own tracks and never ask for `combat:<i>`.
|
||||
* @param {number|null|undefined} round m.public.round
|
||||
* @returns {0|1|null} null when the round is unknown ⇒ the manifest's plain `combat` track
|
||||
*/
|
||||
export function combatTrackFor(round) {
|
||||
const r = Number(round);
|
||||
if (!Number.isFinite(r) || r < 1) return null;
|
||||
return r <= COMBAT_TRACK_SWITCH_ROUND ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Official sound class of a battle unit (audio_data `battle.ON_UNIT_DEAD|BORN.<class>` defaults):
|
||||
* 'enemy' | 'char' (operators: b_char_dead “干员被击倒” / b_char_set) | 'token' (summons: b_char_tokendead /
|
||||
@@ -718,7 +748,13 @@ export function installAudio(deps) {
|
||||
if (deps?.settings) audio.setVolumes(deps.settings);
|
||||
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
|
||||
const sync = (s) => {
|
||||
try { audio.playBgm(bgmKeyFor(deps.selectRoute(s), s.match?.public)); } catch { /* ignore */ }
|
||||
try {
|
||||
const pub = s.match?.public ?? null;
|
||||
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
|
||||
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
|
||||
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.
|
||||
audio.playBgm(bgmKeyFor(deps.selectRoute(s), pub, combatTrackFor(pub?.round)));
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
sync(deps.getState());
|
||||
return deps.subscribe((s, prev) => {
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
// setSkill(on) skill begin, then — when the skill has an idle clip of its own (skill.idle,
|
||||
// not its loop) — that idle between attacks, its loop (the skill's attack clip)
|
||||
// only on attacks (community report #23: 折桠's S2 jump attack looped with no
|
||||
// enemy engaged); a skill without one keeps its loop as the stance; end on stop
|
||||
// enemy engaged); end on stop. A skill clip with NEITHER a Begin nor an own Idle
|
||||
// (德克萨斯 剑雨 and the other 58 instant skills: anims.skill {begin:null,
|
||||
// loop:'Skill', end:null}) plays that clip once for its own length: an instant
|
||||
// skill's flag is off again within the same 0.5 s window (sim skills.js), so
|
||||
// without this the actor fell through to its idle and played no skill clip at
|
||||
// all (player report).
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards); a skeleton without one holds its
|
||||
// idle clip's first frame (GitHub issue #25: the attack loop went on)
|
||||
@@ -36,6 +41,9 @@ const clampN = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
/** Longest wind-up compression (× the rhythm speed) when the look-ahead is shorter than the natural wind-up. */
|
||||
export const MAX_WIND_SPEEDUP = 2.5;
|
||||
|
||||
/** Floor for the one-shot window of a skill clip with no Begin / own Idle (a very short clip still gets frames). */
|
||||
const SKILL_CLIP_MIN = 0.2;
|
||||
|
||||
/**
|
||||
* Attack clip timing (pure). `loopDur` / `hit` are clip seconds (strike frame at `hit`), `interval` and `lead` game
|
||||
* seconds. The clip plays at `ts = loopDur / interval` (one loop per attack); started `lead` before the attack it
|
||||
@@ -100,6 +108,8 @@ export class SpineActor {
|
||||
this.endAt = 0; // landing in endRoles (the next form's) when given
|
||||
this.endRoles = null;
|
||||
this.skillOn = false;
|
||||
this.skillClipOnce = false; // skillBegin: the skill's own clip plays once (a clip with no Begin / own Idle)
|
||||
this.skillEndPending = false; // … and the skill turned off inside it: play its End clip / base when it ends
|
||||
this.attackUntil = 0;
|
||||
this.clock = 0;
|
||||
this.current = '';
|
||||
@@ -360,6 +370,8 @@ export class SpineActor {
|
||||
// his S3's 'skill' off event — lets the deploy clip play out (then the plain idle) instead of cutting it with the End
|
||||
if (!on && this.mode === 'deploy') return;
|
||||
if (on && sk) {
|
||||
this.skillEndPending = false;
|
||||
this.skillClipOnce = false;
|
||||
if (this.has(sk.begin)) {
|
||||
this.mode = 'skillBegin';
|
||||
this._play(sk.begin, false, { mix: 0.08 });
|
||||
@@ -367,8 +379,25 @@ export class SpineActor {
|
||||
// then the skill's own idle until an attack plays its loop (community report #23); without one, the loop
|
||||
const next = this._skillIdle() || (this.has(sk.loop) ? sk.loop : null);
|
||||
if (next) this._queue(next, true);
|
||||
} else if (!this.has(sk.idle) && this.has(sk.loop) && sk.via !== 'attack' && sk.loop !== this.roles.attack?.loop && !this._skillIsBuffOnly()) {
|
||||
// No Begin and no own Idle: the skill's clip IS its animation (德克萨斯 剑雨 — anims.skill {begin:null,
|
||||
// loop:'Skill', end:null}, the 2.17 s clip; 58 instant skills in all). Play it once for its own length. An
|
||||
// instant skill turns the flag off inside the same tick (sim skills.js fires 'skill' 1 and 0 together and only
|
||||
// holds the anim code SKILL for 0.5 s), so this window is what keeps it on screen: the actor used to fall
|
||||
// through to the base clip and show no skill animation at all (player report).
|
||||
this.mode = 'skillBegin';
|
||||
this.skillClipOnce = true;
|
||||
this._play(sk.loop, false, { mix: 0.08 });
|
||||
this.skillBeginUntil = this.clock + Math.max(SKILL_CLIP_MIN, this.dur(sk.loop));
|
||||
} else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
} else if (!on && sk) {
|
||||
// an instant skill switches off while its own one-shot clip runs: let the clip finish — the state machine plays
|
||||
// its End clip (or the base) when the window ends — instead of cutting it with End / base right now
|
||||
if (this.skillClipOnce && this.clock < this.skillBeginUntil) {
|
||||
this.skillEndPending = true;
|
||||
return;
|
||||
}
|
||||
this.skillClipOnce = false;
|
||||
if (this.has(sk.end)) {
|
||||
this.mode = 'skillEnd';
|
||||
this._play(sk.end, false, { mix: 0.08 });
|
||||
@@ -457,7 +486,24 @@ export class SpineActor {
|
||||
}
|
||||
break;
|
||||
case 'skillBegin':
|
||||
if (this.clock >= this.skillBeginUntil) { this.mode = 'base'; if (!this.has(this.roles.skill?.loop)) this._play(this._baseName(), true); }
|
||||
if (this.clock >= this.skillBeginUntil) {
|
||||
const sk = this.roles.skill;
|
||||
const once = this.skillClipOnce;
|
||||
const pending = this.skillEndPending;
|
||||
this.skillClipOnce = false;
|
||||
this.skillEndPending = false;
|
||||
this.mode = 'base';
|
||||
if (pending && this.has(sk?.end)) {
|
||||
// the skill switched off inside its own clip: its End clip closes it now
|
||||
this.mode = 'skillEnd';
|
||||
this._play(sk.end, false, { mix: 0.08 });
|
||||
this.skillEndUntil = this.clock + this.dur(sk.end);
|
||||
} else if (once || pending || !this.has(sk?.loop)) {
|
||||
// the one-shot clip is over (or the skill has no loop clip at all): rest. A Begin clip with a queued next
|
||||
// clip (the loop, or the skill's own idle) needs no play here — it is already on the track
|
||||
this._play(this._baseName(), true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'skillEnd':
|
||||
if (this.clock >= this.skillEndUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
|
||||
|
||||
@@ -1461,6 +1461,9 @@ export class ItemView {
|
||||
if (a?.image) a.image(url).then((img) => { if (!this.destroyed && img && this.info.icon === url) this.plate.texture = itemTexture(String(this.info.defId), img, this.info.color || 0x9aa5a0); }, () => {});
|
||||
}
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
/** A battle snapshot sample (syncBattle syncs every unit of the field): a hand item on a scouted prep board rides
|
||||
* the units as kind 'item' (Match.prepFieldMeta) — follow its position, there is nothing else to animate. */
|
||||
sync(s) { this.x = s.x; this.y = s.y; }
|
||||
update(dt, cam, t) {
|
||||
const lifted = this.lift > 0;
|
||||
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
|
||||
@@ -163,6 +163,7 @@ function MatchScreen() {
|
||||
const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看
|
||||
const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null
|
||||
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
|
||||
const [bondsCollapsed, setBondsCollapsed] = useState(false);
|
||||
const [detail, setDetail] = useState(null); // detail target
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
@@ -1215,8 +1216,20 @@ function MatchScreen() {
|
||||
live=${liveLpNow} spectator=${spectator} />
|
||||
|
||||
<div class="gm__bonds">
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
|
||||
aria-label=${bondsCollapsed ? '展开盟约' : '收起盟约'} title=${bondsCollapsed ? '展开盟约' : '收起盟约'}
|
||||
onKeyDown=${(e) => {
|
||||
// Keep native Space activation here without also firing the global ready / pause shortcut.
|
||||
if (e.key === ' ') e.stopPropagation();
|
||||
}}
|
||||
onClick=${() => {
|
||||
if (!bondsCollapsed && bondOpen?.from === 'strip') setBondOpen(null);
|
||||
setBondsCollapsed(!bondsCollapsed);
|
||||
}}><${Icon} name=${bondsCollapsed ? 'chevronRight' : 'chevronLeft'} /><span>${bondsCollapsed ? '盟约' : '收起'}</span></button>
|
||||
<div id="match-bond-strip" class="gm__bond-list" hidden=${bondsCollapsed}>
|
||||
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
|
||||
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal}
|
||||
|
||||
@@ -155,9 +155,11 @@ function SpectatorBar({ facts, myId, busy, onRemove, onSit }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function InviteBox({ code }) {
|
||||
function InviteBox({ code, name, difficulty }) {
|
||||
const copy = async (what) => {
|
||||
const ok = await copyText(what === 'code' ? code : inviteLink(code));
|
||||
// the copied link carries an invitation line (#103): 「{name}邀请你加入卫戍协议:盟约【{difficulty}】」
|
||||
const invite = t('{name}邀请你加入卫戍协议:盟约【{difficulty}】', { name: name ?? '', difficulty: DIFFICULTY_NAMES[difficulty] ? t(DIFFICULTY_NAMES[difficulty]) : '' });
|
||||
const ok = await copyText(what === 'code' ? code : `${inviteLink(code)} ${invite}`);
|
||||
if (ok) toast(what === 'code' ? t('已复制同盟密钥 {code}', { code }) : t('已复制邀请链接'), 'success');
|
||||
else toast(t('复制失败,请手动复制'), 'warn');
|
||||
};
|
||||
@@ -282,7 +284,7 @@ export function RoomScreen() {
|
||||
<h1 class="topbar__title">${coop ? t('同盟模拟') : t('独立模拟')}<span class="topbar__sep"></span><${DifficultyTag} difficulty=${room.difficulty} size="lg" /></h1>
|
||||
</div>
|
||||
<div class="topbar__right">
|
||||
${coop ? html`<${InviteBox} code=${room.code} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
|
||||
${coop ? html`<${InviteBox} code=${room.code} name=${me.name} difficulty=${room.difficulty} />` : html`<div class="solo-note"><${MicroLabel}>SINGLE OPERATOR<//><span>${t('仅限 1 名博士')}</span></div>`}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
|
||||
const next = nextThreshold(b.count ?? 0, th);
|
||||
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
|
||||
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
|
||||
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
|
||||
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${rec?.noStack ? undefined : b.layers ?? 0}
|
||||
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
|
||||
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
|
||||
title=${`${rec?.name || b.bondId} ${b.count ?? 0}/${next ?? th[th.length - 1] ?? '-'}${b.harmony > 0 ? `(含调和 +${b.harmony})` : ''}`} />
|
||||
@@ -125,7 +125,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
<h3 class="bpop__name">${b.name}</h3>
|
||||
<div class="bpop__facts">
|
||||
${off ? null : html`<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}${harmony ? html`<small class="bpop__hnote" data-harmony=${harmony}>(含${harmonyName} +${harmony})</small>` : null}</span>`}
|
||||
<span>层数 <b class="num t-mint">${layers}</b></span>
|
||||
${b.noStack ? html`<span>层数不显示</span>` : html`<span>层数 <b class="num t-mint">${layers}</b></span>`}
|
||||
<span class=${active ? 't-mint' : 't-lo'}>${active ? `已激活${th.length > 1 ? ` · ${tier} 阶` : ''}` : off ? '本局禁用' : '未激活'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
${th.map((n, i) => html`<span key=${i} class=${cx('bpop__tier', i < tier && 'is-on')}><b class="num">${n}</b><small>${b.maxCount != null ? '名及以下' : '名'}</small></span>`)}
|
||||
</div>
|
||||
${hasNow ? html`<section class="bpop__sec bpop__sec--now">
|
||||
<h4>当前效果 <small class="num">(${layers} 层)</small></h4>
|
||||
<h4>当前效果 ${b.noStack ? null : html`<small class="num">(${layers} 层)</small>`}</h4>
|
||||
<${RichText} as="p" text=${formatBondEffect(b, layers)} class="bpop__desc" />
|
||||
</section>` : null}
|
||||
<section class="bpop__sec">
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// light legal tiles while dragging; the server stays authoritative and may still refuse a move.
|
||||
//
|
||||
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
|
||||
// (LOW, buildable ALL/MELEE) — elite 歌蕾蒂娅 carrying HOK-Y (shared/highGround.js, the player's loadout) on any
|
||||
// deploy tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
|
||||
// (LOW, buildable ALL/MELEE) — a melee chess whose trait reads 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心,
|
||||
// 见行者, any module) on any deploy tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
|
||||
// derived from the tile legend when missing — the legend's `buildable` is the effective type: 深水区 tile_deepsea
|
||||
// refuses deployment, PRTS 深水区 地形信息 "拒绝部署(待补充)", player report #3 after 0.1.0). Tokens follow their
|
||||
// own `position`; a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { meleeOnHighGround } from '../../../../shared/highGround.js';
|
||||
import { pieceDir, rangeTiles } from '../facing.js';
|
||||
import { isObj, tileKey } from './shared.js';
|
||||
import { boardTileOf, fieldTile } from './camera.js';
|
||||
import { deployedRecord, deployedModuleId, fieldsStandIn, standInOf } from './standIn.js';
|
||||
import { deployedRecord, fieldsStandIn, standInOf } from './standIn.js';
|
||||
|
||||
|
||||
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
|
||||
@@ -213,9 +213,10 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y
|
||||
* (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included
|
||||
* (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only.
|
||||
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. A MELEE chess whose trait reads
|
||||
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module) is 'ALL': any deployable
|
||||
* tile, the 高台 included (server/match/board.js positionClass; the owner's decision of 2026-10-05). Every other MELEE
|
||||
* chess is ground-only.
|
||||
*/
|
||||
export function piecePosition(ctx, piece) {
|
||||
if (!isObj(piece)) return null;
|
||||
@@ -223,8 +224,7 @@ export function piecePosition(ctx, piece) {
|
||||
const chess = ctx.getChess(piece.id);
|
||||
// 0.2.0 补位: a chess the player fields as its stand-in is placed by the stand-in's position (server placeClass)
|
||||
const rec = (fieldsStandIn(ctx.priv, chess) && standInOf(chess, ctx.backups)) || chess;
|
||||
const moduleId = deployedModuleId(chess, ctx.priv, ctx.getChess, ctx.backups);
|
||||
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
|
||||
if (meleeOnHighGround(rec)) return 'ALL';
|
||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||
}
|
||||
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
|
||||
|
||||
@@ -90,8 +90,8 @@ export function deployedRecord(chess, priv, getChess, backups) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The module id a deployed piece carries (the placement rule of elite 歌蕾蒂娅's HOK-Y reads it): a stand-in's backup
|
||||
* module, else the player's loadout.
|
||||
* The module id a deployed piece carries: a stand-in's backup module, else the player's loadout. (The 高台 rule no
|
||||
* longer reads it: since the owner's decision of 2026-10-05 it is the trait, whatever the module — shared/highGround.js.)
|
||||
*/
|
||||
export function deployedModuleId(chess, priv, getChess, backups) {
|
||||
if (!isObj(chess)) return null;
|
||||
|
||||
Reference in new issue
Block a user