骰子游戏

This commit is contained in:
zhx 2026-06-11 20:57:14 +08:00
parent bb133314cd
commit 3f95e6b697
18 changed files with 419 additions and 55 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -99,7 +99,8 @@
top: 60px;
left: 16px;
z-index: 30;
display: flex;
display: grid;
grid-template-columns: 46px minmax(0, 1fr) max-content;
align-items: center;
justify-content: space-between;
width: 343px;
@ -109,20 +110,28 @@
.top-back {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 36px;
flex: 0 0 46px;
}
.top-back .layer-img {
position: static;
width: 46px;
height: 36px;
object-fit: fill;
}
.top-game-icon {
position: absolute;
top: 50%;
left: 50%;
position: relative;
justify-self: center;
width: 36px;
height: 36px;
overflow: hidden;
border-radius: 6px;
transform: translate(-50%, -50%);
}
.top-coin {
@ -130,10 +139,11 @@
display: flex;
align-items: center;
justify-content: flex-end;
justify-self: end;
width: max-content;
min-width: 102px;
max-width: 176px;
height: 32px;
height: 36px;
flex: 0 1 auto;
gap: 4px;
overflow: visible;
@ -156,10 +166,12 @@
}
.top-coin span {
display: block;
display: flex;
align-items: center;
min-width: max-content;
max-width: none;
flex: 0 0 auto;
height: 100%;
overflow: visible;
line-height: 20px;
}
@ -181,10 +193,11 @@
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 5px;
height: 100%;
color: #ffd15a;
font-size: 21px;
font-weight: 900;
line-height: 1;
text-shadow:
0 1px 1px rgba(65, 23, 0, 0.86),
0 0 8px rgba(255, 178, 47, 0.74);
@ -732,6 +745,14 @@
display: none;
}
.rps-duel-card.is-opponent.has-opponent-avatar .rps-question {
display: none;
}
.rps-duel-card.is-opponent.has-opponent .rps-duel-status {
display: none;
}
.rps-question {
position: absolute;
inset: 0;
@ -800,6 +821,10 @@
height: 15px;
}
.rps-frame.has-opponent .rps-search-hud {
display: none;
}
.rps-bottom-status {
position: absolute;
left: 74px;
@ -1012,7 +1037,7 @@
}
.rps-victory-button.is-secondary::before {
background-image: url('assets/figma/victory-action-secondary.svg');
background-image: url('assets/figma/victory-action-secondary.png');
filter: drop-shadow(0 0 5px rgba(255, 227, 142, 0.34));
}
@ -1337,9 +1362,20 @@
<div class="rps-duel-card is-opponent">
<img src="assets/figma/opponent-card.png" alt="" />
<div class="rps-duel-avatar">
<img
class="is-empty"
data-role="opponent-card-avatar"
alt=""
/>
<div class="rps-question">?</div>
</div>
<div class="rps-duel-name">?</div>
<div
class="rps-duel-name"
data-role="opponent-card-name"
>
<div>?</div>
<div></div>
</div>
<div class="rps-duel-status">
<img src="assets/figma/search-icon.svg" alt="" />
<span data-i18n="rockGame.matching"
@ -1378,6 +1414,11 @@
<div class="rps-duel-card is-opponent">
<img src="assets/figma/opponent-card.png" alt="" />
<div class="rps-duel-avatar">
<img
class="is-empty"
data-role="opponent-vs-avatar"
alt=""
/>
<div class="rps-question">?</div>
</div>
<div class="rps-duel-name" data-role="opponent-vs-name">
@ -1420,9 +1461,11 @@
<img src="assets/figma/opponent-card.png" alt="" />
<div class="rps-duel-avatar">
<img
src="assets/figma/opponent-question.png"
class="is-empty"
data-role="opponent-battle-avatar"
alt=""
/>
<div class="rps-question">?</div>
</div>
<div
class="rps-duel-name"
@ -1484,7 +1527,7 @@
<section class="rps-screen rps-victory">
<img
class="rps-victory-title"
src="assets/figma/victory-title.svg"
src="assets/figma/victory-title.png"
alt=""
/>
<div class="rps-avatar-ring">
@ -1585,10 +1628,7 @@
selectedGesture: 'rock',
selectedStake: 8000,
opponentGesture: 'paper',
opponent: {
nickname: 'NameName',
display_user_id: 'Name...',
},
opponent: {},
config: null,
match: null,
busy: false,
@ -1818,6 +1858,17 @@
if (nodes[1]) nodes[1].textContent = displaySubName(user);
}
function setOpponentNameLines(selector, user, hasDetails) {
var nodes = document.querySelectorAll(selector + ' div');
if (!hasDetails) {
if (nodes[0]) nodes[0].textContent = '?';
if (nodes[1]) nodes[1].textContent = '';
return;
}
if (nodes[0]) nodes[0].textContent = displayName(user);
if (nodes[1]) nodes[1].textContent = displaySubName(user);
}
function setHand(node, gesture) {
var meta = gestures[gesture] || gestures.rock;
if (!node) return;
@ -1912,12 +1963,52 @@
);
}
function hasOpponentDetails(user) {
user = user || {};
return Boolean(
avatarURL(user) ||
user.nickname ||
user.username ||
user.name ||
user.display_user_id ||
user.user_id
);
}
function renderOpponent() {
var opponent = state.opponent || {};
setNameLines('[data-role="opponent-vs-name"]', opponent);
setNameLines(
var avatar = avatarURL(opponent);
var hasDetails = hasOpponentDetails(opponent);
frame.classList.toggle('has-opponent', hasDetails);
// 匹配成功后同一份对手资料要同步到等待卡、VS 卡和对战卡;否则某个屏幕仍停留在搜索态,会出现头像未替换或搜索图标残留。
setOpponentNameLines(
'[data-role="opponent-card-name"]',
opponent,
hasDetails
);
setOpponentNameLines(
'[data-role="opponent-vs-name"]',
opponent,
hasDetails
);
setOpponentNameLines(
'[data-role="opponent-battle-name"]',
opponent
opponent,
hasDetails
);
setImage(role('opponent-card-avatar'), avatar);
setImage(role('opponent-vs-avatar'), avatar);
setImage(role('opponent-battle-avatar'), avatar);
each(
document.querySelectorAll('.rps-duel-card.is-opponent'),
function (card) {
// 有对手资料就隐藏 Matching/Search 状态;只有头像真实可用时才隐藏问号兜底,避免无头像用户显示成空圆。
card.classList.toggle('has-opponent', hasDetails);
card.classList.toggle(
'has-opponent-avatar',
Boolean(avatar)
);
}
);
setHand(
role('opponent-battle-hand'),
@ -2147,6 +2238,9 @@
if (readGesture(opponent)) {
state.opponentGesture = readGesture(opponent);
}
} else {
// 等待匹配时必须清空上一局对手资料,否则搜索页会复用旧头像并提前隐藏搜索状态。
state.opponent = {};
}
renderSelection();
renderOpponent();
@ -2361,6 +2455,8 @@
state.busy = true;
state.roundID += 1;
state.match = null;
state.opponent = {};
renderOpponent();
setScreen('searching');
postBridge('gameRPSMatchRequested', {
game_code: GAME_CODE,
@ -2400,7 +2496,8 @@
.then(function (data) {
var match = matchFromData(data);
trackSelfGameEvent('match_success', {
match_id: match && (match.match_id || match.matchId),
match_id:
match && (match.match_id || match.matchId),
stake_coin: state.selectedStake,
gesture: state.selectedGesture,
duration_ms: Math.round(
@ -2410,19 +2507,19 @@
handleMatchData(data);
})
.catch(function (error) {
trackSelfGameEvent('match_api', {
stake_coin: state.selectedStake,
gesture: state.selectedGesture,
duration_ms: Math.round(
performance.now() - matchStartedAt
),
success: false,
error_code: error && error.message,
trackSelfGameEvent('match_api', {
stake_coin: state.selectedStake,
gesture: state.selectedGesture,
duration_ms: Math.round(
performance.now() - matchStartedAt
),
success: false,
error_code: error && error.message,
});
state.busy = false;
showMessage(error.message);
setScreen('lobby');
});
state.busy = false;
showMessage(error.message);
setScreen('lobby');
});
}
function startMockMatch() {
@ -2430,10 +2527,12 @@
state.busy = true;
state.roundID += 1;
var roundID = state.roundID;
state.opponent = {};
state.opponentGesture = chooseOpponentGesture();
state.opponent = {
nickname: 'NameName',
display_user_id: 'Name...',
avatar: 'assets/figma/game-icon.png',
};
renderOpponent();
setScreen('searching');
@ -2532,6 +2631,7 @@
state.busy = false;
state.roundID += 1;
state.match = null;
state.opponent = {};
frame.classList.remove('is-vs-impact', 'is-screen-impact');
setScreen('lobby');
renderSelection();

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

View File

@ -263,6 +263,32 @@
margin-bottom: 5px;
}
.top-coin.is-crediting {
animation: top-coin-credit-pulse 720ms ease-out both;
}
.coin-credit-layer {
position: absolute;
inset: 0;
z-index: 60;
pointer-events: none;
}
.coin-credit-flyer {
position: absolute;
left: var(--coin-credit-x);
top: var(--coin-credit-y);
width: 24px;
height: 24px;
opacity: 0;
filter: drop-shadow(0 0 6px rgba(255, 203, 67, 0.86));
transform: translate3d(0, 0, 0) scale(0.78);
animation: coin-credit-fly 920ms
cubic-bezier(0.2, 0.82, 0.16, 1) forwards;
animation-delay: var(--coin-credit-delay, 0ms);
will-change: transform, opacity;
}
.language-switcher {
position: absolute;
top: 41px;
@ -1074,27 +1100,25 @@
.dice-score {
position: absolute;
top: 302px;
top: 379px;
z-index: 12;
width: 88px;
min-height: 34px;
width: 132px;
height: 132px;
visibility: hidden;
opacity: 0;
color: #ffffff;
font-size: 18px;
font-weight: 900;
line-height: 22px;
text-align: center;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.82);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
transition: opacity 180ms ease;
}
.dice-score-self {
left: 34px;
left: 8px;
}
.dice-score-opponent {
left: 255px;
left: 230px;
}
.dice-frame.has-scores .dice-score {
@ -1102,6 +1126,14 @@
opacity: 1;
}
.dice-score img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
}
.dice-roll-effect {
position: absolute;
z-index: 11;
@ -1367,6 +1399,7 @@
.match-battle-only,
.opponent-battle-only,
.dice-roll-effect,
.coin-credit-flyer,
.versus-only,
.victory-only {
transition: none;
@ -1381,7 +1414,9 @@
.dice-frame.is-matching,
.dice-frame.is-searching .center-search-bg,
.dice-frame.is-searching .search-indicator img,
.dice-roll-effect.is-fallback::before {
.dice-roll-effect.is-fallback::before,
.coin-credit-flyer,
.top-coin.is-crediting {
animation: none;
}
}
@ -1593,6 +1628,49 @@
}
}
@keyframes coin-credit-fly {
0% {
opacity: 0;
transform: translate3d(0, 0, 0) scale(0.78) rotate(0deg);
}
12% {
opacity: 1;
}
58% {
opacity: 1;
transform: translate3d(
calc(var(--coin-credit-dx) * 0.72),
calc(var(--coin-credit-dy) * 0.58 - 34px),
0
)
scale(1.05) rotate(220deg);
}
100% {
opacity: 0;
transform: translate3d(
var(--coin-credit-dx),
var(--coin-credit-dy),
0
)
scale(0.36) rotate(520deg);
}
}
@keyframes top-coin-credit-pulse {
0% {
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(255, 211, 79, 0));
}
42% {
transform: scale(1.12);
filter: drop-shadow(0 0 13px rgba(255, 211, 79, 0.86));
}
100% {
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(255, 211, 79, 0));
}
}
@keyframes countdown-number-pop {
0% {
opacity: 0;
@ -2435,6 +2513,8 @@
var pollTimer = 0;
var roundSequence = 0;
var pageStartedAt = performance.now();
var coinCreditFrame = 0;
var coinCreditLayer = null;
var rollEffectPlayers = {
self: null,
opponent: null,
@ -2739,6 +2819,147 @@
);
}
function cancelCoinCreditAnimation() {
if (coinCreditFrame) {
window.cancelAnimationFrame(coinCreditFrame);
coinCreditFrame = 0;
}
if (coinCreditLayer && coinCreditLayer.parentNode) {
coinCreditLayer.parentNode.removeChild(coinCreditLayer);
}
coinCreditLayer = null;
var topCoin = document.querySelector('.top-coin');
if (topCoin) topCoin.classList.remove('is-crediting');
}
function framePointFromRect(rect, anchorX, anchorY) {
var frameRect = frame.getBoundingClientRect();
var scale = frameRect.width / DESIGN_WIDTH || 1;
return {
x:
(rect.left +
rect.width * anchorX -
frameRect.left) /
scale,
y:
(rect.top + rect.height * anchorY - frameRect.top) /
scale,
};
}
function createCoinCreditLayer() {
cancelCoinCreditAnimation();
coinCreditLayer = document.createElement('div');
coinCreditLayer.className = 'coin-credit-layer';
frame.appendChild(coinCreditLayer);
return coinCreditLayer;
}
function burstCreditCoins(start, end) {
var layer = createCoinCreditLayer();
var offsets = [
[-8, 6],
[12, -4],
[3, 16],
[20, 10],
[-16, -3],
[8, -17],
[-2, -12],
[17, 22],
];
offsets.forEach(function (offset, index) {
var coin = document.createElement('img');
coin.className = 'coin-credit-flyer';
coin.src = 'assets/figma/coin.png';
coin.alt = '';
coin.draggable = false;
coin.setAttribute('aria-hidden', 'true');
coin.style.setProperty(
'--coin-credit-x',
start.x + offset[0] + 'px'
);
coin.style.setProperty(
'--coin-credit-y',
start.y + offset[1] + 'px'
);
coin.style.setProperty(
'--coin-credit-dx',
end.x - start.x - offset[0] + 'px'
);
coin.style.setProperty(
'--coin-credit-dy',
end.y - start.y - offset[1] + 'px'
);
coin.style.setProperty(
'--coin-credit-delay',
index * 64 + 'ms'
);
layer.appendChild(coin);
});
schedule(function () {
if (coinCreditLayer === layer) {
layer.remove();
coinCreditLayer = null;
}
}, 1500);
}
function animateCoinNumber(startValue, targetValue) {
var start = Math.max(
0,
Math.trunc(Number(startValue) || 0)
);
var target = Math.max(
0,
Math.trunc(Number(targetValue) || 0)
);
var startedAt = performance.now();
var duration = 1180;
function tick(now) {
var progress = Math.min(
Math.max((now - startedAt) / duration, 0),
1
);
var eased = 1 - Math.pow(1 - progress, 3);
setCoin(start + (target - start) * eased);
if (progress < 1) {
coinCreditFrame =
window.requestAnimationFrame(tick);
return;
}
coinCreditFrame = 0;
setCoin(target);
}
if (coinCreditFrame) {
window.cancelAnimationFrame(coinCreditFrame);
}
coinCreditFrame = window.requestAnimationFrame(tick);
}
function playVictoryCoinCredit(startValue, targetValue) {
var topCoin = document.querySelector('.top-coin');
var topCoinIcon = topCoin && topCoin.querySelector('img');
if (!topCoinIcon || targetValue <= startValue) {
setCoin(targetValue);
return false;
}
var start = { x: 95, y: 584 };
var end = framePointFromRect(
topCoinIcon.getBoundingClientRect(),
0.5,
0.5
);
burstCreditCoins(start, end);
animateCoinNumber(startValue, targetValue);
topCoin.classList.remove('is-crediting');
void topCoin.offsetWidth;
topCoin.classList.add('is-crediting');
schedule(function () {
topCoin.classList.remove('is-crediting');
}, 780);
return true;
}
function displayName(user) {
user = user || {};
return (
@ -2960,6 +3181,7 @@
function resetFrame() {
stopRollEffects();
cancelCoinCreditAnimation();
frame.classList.remove(
'is-searching',
'is-matching',
@ -3085,10 +3307,39 @@
return null;
}
function scoreText(participant) {
function dicePointValue(participant) {
var points = (participant && participant.dice_points) || [];
if (!points.length) return '';
return String(Number(points[0] || 0));
var point = Number(points[0] || 0);
if (!isFinite(point) || point < 1 || point > 6) return '';
return String(Math.floor(point));
}
function diceScoreImage(point, side) {
if (!point) return '';
return (
'assets/figma/dice-' +
(side === 'self' ? 'red' : 'blue') +
'-' +
point +
'.png'
);
}
function setDiceScore(node, point, side) {
if (!node) return;
node.textContent = '';
if (!point) {
node.removeAttribute('data-score-value');
return;
}
node.setAttribute('data-score-value', point);
var img = document.createElement('img');
img.src = diceScoreImage(point, side);
img.alt = '';
img.draggable = false;
img.setAttribute('aria-hidden', 'true');
node.appendChild(img);
}
function isDrawReroll(match) {
@ -3162,10 +3413,14 @@
setImage(role('opponent-avatar'), '', '');
setFrame(role('opponent-avatar-frame'), {});
}
var selfScore = scoreText(self);
var opponentScore = scoreText(opponent);
setText(role('self-score'), selfScore);
setText(role('opponent-score'), opponentScore);
var selfScore = dicePointValue(self);
var opponentScore = dicePointValue(opponent);
setDiceScore(role('self-score'), selfScore, 'self');
setDiceScore(
role('opponent-score'),
opponentScore,
'opponent'
);
if (selfScore && opponentScore) {
frame.classList.add('has-scores');
} else {
@ -3381,8 +3636,18 @@
formatCoin((winner && winner.payout_coin) || 0)
);
if (self && self.balance_after > 0) {
setCoin(self.balance_after);
var balanceAfter = Math.trunc(
Number(self.balance_after) || 0
);
if (winnerIsSelf && balanceAfter > state.balanceCoin) {
return playVictoryCoinCredit(
state.balanceCoin,
balanceAfter
);
}
setCoin(balanceAfter);
}
return false;
}
function refreshBalance() {
@ -3401,8 +3666,7 @@
renderMatchParticipants(match);
resetFrame();
frame.classList.add('is-victory');
renderVictory(match);
refreshBalance();
if (!renderVictory(match)) refreshBalance();
trackSelfGameEvent('settlement_show', {
match_id: match.match_id,
result: match.result,
@ -3557,8 +3821,8 @@
resetFrame();
setPhaseText('');
setActionText(matchingText());
setText(role('self-score'), '');
setText(role('opponent-score'), '');
setDiceScore(role('self-score'), '', 'self');
setDiceScore(role('opponent-score'), '', 'opponent');
setText(document.querySelector('.opponent-mark'), '?');
setText(
document.querySelector('.matching-label'),