You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

588 lines
17 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
import { BuffType, type ActiveBuff, type BuffChargeState } from '../game/types';
import { BUFF_COOLDOWN_MS, buffMaxChargesForHero } from '../network/buffMap';
import { BUFF_VISUAL, buffUiStrings } from './buffMeta';
import { purchaseBuffRefill } from '../network/api';
import type { HeroResponse } from '../network/api';
import { getTelegramUserId } from '../shared/telegram';
import { useT, t } from '../i18n';
// ---- Types ----
interface BuffBarProps {
buffs: ActiveBuff[];
/** Client-side cooldown end timestamps (ms); backend does not persist these yet. */
cooldownEndsAt: Partial<Record<BuffType, number>>;
/** Per-buff charge quotas from the server. */
buffCharges: Partial<Record<BuffType, BuffChargeState>>;
/** When true, UI max charge labels use subscriber caps (×2). */
subscriptionActive?: boolean;
/** When true (e.g. hero dead), buff taps do nothing and refill flow is blocked. */
buffsLocked?: boolean;
nowMs: number;
onActivate: (type: BuffType) => void;
/** Called when a buff refill purchase returns an updated hero */
onHeroUpdated?: (hero: HeroResponse) => void;
}
interface BuffButtonProps {
buff: ActiveBuff;
charge: BuffChargeState | undefined;
maxCharges: number;
onActivate: () => void;
onRefill?: (type: BuffType) => void;
nowMs: number;
buffsLocked?: boolean;
}
// ---- Tooltip ----
const LONG_PRESS_MS = 400;
const TOOLTIP_AUTO_HIDE_MS = 2500;
/** When false, the green refill CTA in the hover tooltip is hidden (purchase still reachable from tap flow if any). */
const SHOW_BUFF_REFILL_BUTTON_IN_TOOLTIP = false;
const tooltipStyle: CSSProperties = {
position: 'absolute',
bottom: '110%',
left: '50%',
transform: 'translateX(-50%)',
padding: '6px 10px',
borderRadius: 8,
backgroundColor: 'rgba(10, 10, 20, 0.92)',
border: '1px solid rgba(255,255,255,0.15)',
color: '#eee',
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 100,
textAlign: 'center',
lineHeight: 1.4,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
};
const tooltipArrow: CSSProperties = {
position: 'absolute',
bottom: -5,
left: '50%',
transform: 'translateX(-50%)',
width: 0,
height: 0,
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: '5px solid rgba(10, 10, 20, 0.92)',
};
// ---- Charge Badge ----
const chargeBadgeBase: CSSProperties = {
position: 'absolute',
top: -4,
right: -4,
minWidth: 16,
height: 16,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
fontWeight: 700,
lineHeight: 1,
padding: '0 3px',
zIndex: 10,
pointerEvents: 'none',
border: '1px solid rgba(0,0,0,0.4)',
};
// ---- Radial Cooldown SVG ----
function RadialCooldown({ progress }: { progress: number }) {
if (progress <= 0) return null;
const radius = 22;
const cx = 25;
const cy = 25;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - progress);
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
transform: 'rotate(-90deg)',
pointerEvents: 'none',
}}
viewBox="0 0 50 50"
>
<circle
cx={cx}
cy={cy}
r={radius}
fill="rgba(0,0,0,0.5)"
stroke="none"
strokeDasharray={circumference}
strokeDashoffset={offset}
/>
<circle
cx={cx}
cy={cy}
r={radius}
fill="none"
stroke="rgba(255,255,255,0.3)"
strokeWidth={2}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
/>
</svg>
);
}
// ---- Helpers ----
/** Format an ISO timestamp as HH:MM in local time. */
function formatTimeHHMM(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '??:??';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ---- Buff Button ----
const buttonBase: CSSProperties = {
position: 'relative',
border: '2px solid rgba(255,255,255,0.2)',
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
cursor: 'pointer',
userSelect: 'none',
WebkitTapHighlightColor: 'transparent',
};
function BuffButton({ buff, charge, maxCharges, onActivate, onRefill, nowMs, buffsLocked }: BuffButtonProps) {
const tr = useT();
const visual = BUFF_VISUAL[buff.type];
const { label, desc } = buffUiStrings(tr, buff.type);
const meta = { ...visual, label, desc };
const [pressed, setPressed] = useState(false);
const [showTooltip, setShowTooltip] = useState(false);
const [showRefillConfirm, setShowRefillConfirm] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const didLongPress = useRef(false);
const remainingEffectMs =
buff.expiresAtMs != null ? Math.max(0, buff.expiresAtMs - nowMs) : buff.remainingMs;
const isActive = remainingEffectMs > 0;
const isOnCooldown = buff.cooldownRemainingMs > 0;
const cooldownProgress =
buff.cooldownMs > 0 ? buff.cooldownRemainingMs / buff.cooldownMs : 0;
const cdSec = Math.ceil((buff.cooldownMs || 0) / 1000);
// Charge state
const remaining = charge?.remaining;
const hasChargeData = remaining != null;
const isOutOfCharges = hasChargeData && remaining === 0;
const isDisabled = isOnCooldown || (isOutOfCharges && !isActive) || !!buffsLocked;
useEffect(() => {
return () => {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
};
}, []);
useEffect(() => {
if (buffsLocked) {
setShowTooltip(false);
setShowRefillConfirm(false);
}
}, [buffsLocked]);
const openTooltip = (): void => {
setShowTooltip(true);
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
autoHideTimer.current = setTimeout(() => setShowTooltip(false), TOOLTIP_AUTO_HIDE_MS);
};
const closeTooltip = (): void => {
setShowTooltip(false);
if (autoHideTimer.current) clearTimeout(autoHideTimer.current);
};
const handleTouchStart = (): void => {
if (buffsLocked) return;
didLongPress.current = false;
longPressTimer.current = setTimeout(() => {
didLongPress.current = true;
openTooltip();
}, LONG_PRESS_MS);
};
const handleTouchEnd = (): void => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const handleClick = (): void => {
if (buffsLocked) return;
if (didLongPress.current) {
didLongPress.current = false;
return;
}
setPressed(true);
window.setTimeout(() => setPressed(false), 140);
// When out of charges, show refill confirmation instead of doing nothing
if (isOutOfCharges && !isOnCooldown && !isActive) {
setShowRefillConfirm(true);
return;
}
if (isDisabled) return;
onActivate();
};
const dimmedFaceOpacity = buffsLocked
? 0.4
: isDisabled
? (isOutOfCharges && !isOnCooldown ? 0.3 : 0.55)
: 1;
const hitStyle: CSSProperties = {
position: 'relative',
width: 44,
height: 50,
padding: 0,
border: 'none',
background: 'transparent',
pointerEvents: buffsLocked ? 'none' : 'auto',
cursor: buffsLocked || isDisabled ? 'not-allowed' : 'pointer',
transform: pressed ? 'scale(0.94)' : 'scale(1)',
transition: 'transform 80ms ease',
};
const activatorFaceStyle: CSSProperties = {
...buttonBase,
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
borderRadius: 10,
borderColor: isActive ? meta.color : pressed ? '#fff' : 'rgba(255,255,255,0.2)',
boxShadow: isActive
? `0 0 12px ${meta.color}`
: pressed
? `0 0 10px rgba(255,255,255,0.5), inset 0 0 12px ${meta.color}66`
: 'none',
opacity: dimmedFaceOpacity,
transition: 'opacity 150ms ease, box-shadow 80ms ease, border-color 80ms ease',
pointerEvents: 'none',
};
return (
<button
type="button"
style={hitStyle}
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
onMouseEnter={buffsLocked ? undefined : openTooltip}
onMouseLeave={buffsLocked ? undefined : closeTooltip}
aria-disabled={isDisabled}
aria-label={`${meta.label}: ${meta.desc}`}
>
{/* Dim only the activator (chrome + icon); tooltip is a sibling so it stays full opacity. */}
<div style={activatorFaceStyle}>
{/* Inner wrapper clips cooldown overlay to button bounds */}
<div
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
borderRadius: 8,
}}
>
<RadialCooldown progress={cooldownProgress} />
</div>
<span style={{ fontSize: 18, lineHeight: 1, position: 'relative' }}>{meta.icon}</span>
<span style={{ fontSize: 8, color: '#ccc', marginTop: 1, position: 'relative' }}>{meta.label}</span>
{isActive && !isOnCooldown && (
<span
style={{
position: 'absolute',
bottom: 2,
fontSize: 9,
fontWeight: 700,
color: meta.color,
textShadow: '0 1px 2px rgba(0,0,0,0.9)',
}}
>
{Math.ceil(remainingEffectMs / 1000)}s
</span>
)}
{isOnCooldown && (
<span
style={{
position: 'absolute',
fontSize: 11,
fontWeight: 700,
color: '#fff',
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
}}
>
{Math.ceil(buff.cooldownRemainingMs / 1000)}s
</span>
)}
{/* Charge badge */}
{hasChargeData && (
<span
style={{
...chargeBadgeBase,
backgroundColor: isOutOfCharges ? '#aa2222' : 'rgba(30, 90, 180, 0.9)',
color: isOutOfCharges ? '#ffaaaa' : '#fff',
}}
>
{remaining}
</span>
)}
</div>
{showTooltip && (
<div style={tooltipStyle}>
<div style={{ color: meta.color, fontWeight: 700, marginBottom: 2 }}>{meta.label}</div>
<div style={{ color: '#ddd', fontWeight: 400 }}>{meta.desc}</div>
{cdSec > 0 && <div style={{ color: '#999', fontSize: 10, marginTop: 2 }}>CD: {cdSec}s</div>}
{hasChargeData && (
<div
style={{
fontSize: 10,
marginTop: 3,
color: isOutOfCharges ? '#ff8844' : '#aaccff',
}}
>
{tr.charges}: {remaining}/{maxCharges}
</div>
)}
{isOutOfCharges && charge?.periodEnd && (
<div style={{ fontSize: 10, marginTop: 1, color: '#ff6644' }}>
{tr.refillsAt} {formatTimeHHMM(charge.periodEnd)}
</div>
)}
{SHOW_BUFF_REFILL_BUTTON_IN_TOOLTIP && isOutOfCharges && onRefill && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowRefillConfirm(true);
closeTooltip();
}}
style={{
marginTop: 4,
padding: '3px 10px',
fontSize: 10,
fontWeight: 700,
color: '#fff',
backgroundColor: 'rgba(100, 180, 60, 0.85)',
border: '1px solid rgba(100, 180, 60, 0.6)',
borderRadius: 4,
cursor: 'pointer',
pointerEvents: 'auto',
}}
>
{tr.refill}
</button>
)}
<div style={tooltipArrow} />
</div>
)}
{/* Refill confirmation popup */}
{showRefillConfirm && (
<div
style={{
position: 'absolute',
bottom: '115%',
left: '50%',
transform: 'translateX(-50%)',
padding: '8px 12px',
borderRadius: 8,
backgroundColor: 'rgba(10, 10, 20, 0.95)',
border: '1px solid rgba(100, 180, 60, 0.4)',
color: '#eee',
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
zIndex: 110,
textAlign: 'center',
boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
}}
>
<div style={{ marginBottom: 6 }}>
{t(tr.refillQuestion, { label: meta.label })}
</div>
<div style={{ fontSize: 10, color: '#aaa', marginBottom: 8 }}>
{buff.type === BuffType.Resurrection ? '150\u20BD' : '50\u20BD'}
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'center' }}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowRefillConfirm(false);
onRefill?.(buff.type);
}}
style={{
padding: '4px 12px',
fontSize: 10,
fontWeight: 700,
color: '#fff',
backgroundColor: 'rgba(100, 180, 60, 0.85)',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
}}
>
{tr.refill}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowRefillConfirm(false);
}}
style={{
padding: '4px 12px',
fontSize: 10,
fontWeight: 700,
color: '#aaa',
backgroundColor: 'rgba(255,255,255,0.08)',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
}}
>
{tr.cancel}
</button>
</div>
</div>
)}
</button>
);
}
// ---- Buff Bar ----
const barStyle: CSSProperties = {
display: 'flex',
gap: 4,
justifyContent: 'center',
flexWrap: 'wrap',
padding: '6px 0',
};
/** All buff types that should always be visible as buttons */
const ALL_BUFF_TYPES: BuffType[] = [
BuffType.Heal, BuffType.Rage, BuffType.Shield,
BuffType.Rush, BuffType.PowerPotion, BuffType.WarCry,
BuffType.Luck,
];
function getBuffEntry(
type: BuffType,
activeBuffs: ActiveBuff[],
cooldownEndsAt: Partial<Record<BuffType, number>>,
nowMs: number,
): ActiveBuff {
const active = activeBuffs.find((b) => b.type === type);
const cdEnd = cooldownEndsAt[type];
const cdRem = cdEnd != null ? Math.max(0, cdEnd - nowMs) : 0;
const cdFull = active?.cooldownMs || BUFF_COOLDOWN_MS[type];
if (active) {
const exp = active.expiresAtMs;
const remainingMs = exp != null ? Math.max(0, exp - nowMs) : active.remainingMs;
return {
...active,
remainingMs,
cooldownMs: active.cooldownMs || cdFull,
cooldownRemainingMs: cdRem,
};
}
return {
type,
remainingMs: 0,
durationMs: 0,
cooldownMs: cdFull,
cooldownRemainingMs: cdRem,
};
}
export function BuffBar({
buffs,
cooldownEndsAt,
buffCharges,
subscriptionActive,
buffsLocked,
nowMs,
onActivate,
onHeroUpdated,
}: BuffBarProps) {
const handleActivate = useCallback(
(type: BuffType) => () => onActivate(type),
[onActivate],
);
const handleRefill = useCallback(
(type: BuffType) => {
const telegramId = getTelegramUserId() ?? undefined;
purchaseBuffRefill(type, telegramId)
.then((hero) => {
onHeroUpdated?.(hero);
})
.catch((err) => {
console.warn('[BuffBar] purchaseBuffRefill failed:', err);
});
},
[onHeroUpdated],
);
return (
<div style={barStyle}>
{ALL_BUFF_TYPES.map((type) => {
const buff = getBuffEntry(type, buffs, cooldownEndsAt, nowMs);
const charge = buffCharges[type];
const maxCharges = buffMaxChargesForHero(type, subscriptionActive);
return (
<BuffButton
key={type}
buff={buff}
charge={charge}
maxCharges={maxCharges}
onActivate={handleActivate(type)}
onRefill={handleRefill}
nowMs={nowMs}
buffsLocked={buffsLocked}
/>
);
})}
</div>
);
}