mejoras de UI AnimatedNumber
This commit is contained in:
84
client/src/views/games/AnimatedNumber.vue
Normal file
84
client/src/views/games/AnimatedNumber.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="anim-number" :class="[ animating ? (dir === 'inc' ? 'dir-inc' : 'dir-dec') : '', running ? 'run' : '', (!running && animating) ? 'prep' : '', pulse ? 'pulse' : '' ]">
|
||||||
|
<div class="viewport" ref="viewportEl">
|
||||||
|
<span class="measure">{{ display }}</span>
|
||||||
|
<span v-if="animating" class="val old">{{ prev }}</span>
|
||||||
|
<span class="val current">{{ display }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, nextTick, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ value: number; durationMs?: number; pulseOnFirst?: boolean }>();
|
||||||
|
|
||||||
|
const prev = ref<number>(props.value ?? 0);
|
||||||
|
const display = ref<number>(props.value ?? 0);
|
||||||
|
const animating = ref(false);
|
||||||
|
const running = ref(false);
|
||||||
|
const dir = ref<'inc' | 'dec'>('inc');
|
||||||
|
// handled via CSS classes start-inc/start-dec
|
||||||
|
const pulse = ref(false);
|
||||||
|
const viewportEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.pulseOnFirst) {
|
||||||
|
pulse.value = true;
|
||||||
|
setTimeout(() => { pulse.value = false; }, 600);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.value, async (nv, ov) => {
|
||||||
|
if (nv === ov) return;
|
||||||
|
const d = (props.durationMs ?? 1500);
|
||||||
|
dir.value = nv > prev.value ? 'inc' : 'dec';
|
||||||
|
// Setup animation
|
||||||
|
display.value = nv;
|
||||||
|
animating.value = true;
|
||||||
|
running.value = false;
|
||||||
|
await nextTick();
|
||||||
|
// Trigger run
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
running.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
// End animation
|
||||||
|
prev.value = nv;
|
||||||
|
animating.value = false;
|
||||||
|
running.value = false;
|
||||||
|
}, d);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.anim-number { display:inline-block; position: relative; height: 1.2em; overflow: hidden; vertical-align: baseline; }
|
||||||
|
.viewport { position: relative; display:inline-block; height: 1.2em; }
|
||||||
|
.measure { visibility: hidden; display:inline-block; }
|
||||||
|
.val { position: absolute; left: 0; right: 0; top: 0; transform: translateY(0); transition: transform 1.5s ease, opacity 1.5s ease; line-height: 1.2em; will-change: transform, opacity; }
|
||||||
|
.anim-number.prep .val { transition: none !important; }
|
||||||
|
.anim-number.dir-inc .current { transform: translateY(-100%); }
|
||||||
|
.anim-number.dir-dec .current { transform: translateY(100%); }
|
||||||
|
.anim-number.dir-inc .current,
|
||||||
|
.anim-number.dir-dec .current { opacity: 0.2; }
|
||||||
|
.anim-number.dir-inc.run .current,
|
||||||
|
.anim-number.dir-dec.run .current { transform: translateY(0); opacity: 1; }
|
||||||
|
.old { opacity: 1; }
|
||||||
|
.current { opacity: 1; }
|
||||||
|
|
||||||
|
/* Increase: old goes down, new comes from top */
|
||||||
|
.dir-inc.run .old { transform: translateY(100%); opacity: 0; }
|
||||||
|
.dir-inc.run .current { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* Decrease: old goes up, new comes from bottom */
|
||||||
|
.dir-dec.run .old { transform: translateY(-100%); opacity: 0; }
|
||||||
|
.dir-dec.run .current { transform: translateY(0); }
|
||||||
|
|
||||||
|
/* Emphasis pulse */
|
||||||
|
.pulse { animation: pulse-pop 0.6s ease; }
|
||||||
|
@keyframes pulse-pop {
|
||||||
|
0% { transform: scale(0.9); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,26 +7,29 @@
|
|||||||
<div class="tokens">
|
<div class="tokens">
|
||||||
<div class="token pill">
|
<div class="token pill">
|
||||||
<span class="icon">🦃</span>
|
<span class="icon">🦃</span>
|
||||||
<span class="val">{{ player.pavoTokens ?? 0 }}</span>
|
<span class="val"><AnimatedNumber :value="player.pavoTokens ?? 0" /></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="token pill">
|
<div class="token pill">
|
||||||
<span class="icon">🌽</span>
|
<span class="icon">🌽</span>
|
||||||
<span class="val">{{ player.eloteTokens ?? 0 }}</span>
|
<span class="val"><AnimatedNumber :value="player.eloteTokens ?? 0" /></span>
|
||||||
</div>
|
|
||||||
<div v-if="(player.shameTokens ?? 0) > 0" class="token pill subtle">
|
|
||||||
<span class="icon">😶</span>
|
|
||||||
<span class="val">{{ player.shameTokens }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Transition name="pop">
|
||||||
|
<div v-if="(player.shameTokens ?? 0) > 0" class="token pill subtle shame-pill">
|
||||||
|
<span class="icon">😶</span>
|
||||||
|
<span class="val"><AnimatedNumber :value="player.shameTokens ?? 0" :pulseOnFirst="true" /></span>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
<div class="score">
|
<div class="score">
|
||||||
<span class="label">Puntuación</span>
|
<span class="label">Puntuación</span>
|
||||||
<span class="value">{{ displayScore }}</span>
|
<span class="value"><AnimatedNumber :value="displayScore" /></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import AnimatedNumber from './AnimatedNumber.vue';
|
||||||
|
|
||||||
interface PlayerView {
|
interface PlayerView {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -58,8 +61,13 @@ const primary = computed(() => props.player.color || '#667eea');
|
|||||||
.pill { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:999px; background:#f7f7f7; border:1px solid #eee; }
|
.pill { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:999px; background:#f7f7f7; border:1px solid #eee; }
|
||||||
.pill.subtle { background:#fafafa; color:#666; }
|
.pill.subtle { background:#fafafa; color:#666; }
|
||||||
.icon { font-size: 16px; }
|
.icon { font-size: 16px; }
|
||||||
.val { font-weight: 600; color:#333; }
|
.val { font-weight: 600; color:#333; display:inline-block; min-width: 1ch; }
|
||||||
.score { display:flex; align-items:center; justify-content:space-between; margin-top:6px; padding:8px; border-radius:10px; background:linear-gradient(135deg, color-mix(in srgb, var(--primary) 10%, white) 0%, #ffffff 100%); border:1px solid color-mix(in srgb, var(--primary) 30%, #e6e9ff); }
|
.score { display:flex; align-items:center; justify-content:space-between; margin-top:6px; padding:8px; border-radius:10px; background:linear-gradient(135deg, color-mix(in srgb, var(--primary) 10%, white) 0%, #ffffff 100%); border:1px solid color-mix(in srgb, var(--primary) 30%, #e6e9ff); }
|
||||||
.score .label { font-size:12px; color: var(--primary); font-weight:700; }
|
.score .label { font-size:12px; color: var(--primary); font-weight:700; }
|
||||||
.score .value { font-size:18px; font-weight:800; color: var(--primary); }
|
.score .value { font-size:18px; font-weight:800; color: var(--primary); display:flex; align-items:center; height: 1.2em; line-height: 1.2em; }
|
||||||
|
|
||||||
|
/* Emphasis on shame token appear */
|
||||||
|
.pop-enter-from { opacity: 0; transform: scale(0.9); }
|
||||||
|
.pop-enter-to { opacity: 1; transform: scale(1); }
|
||||||
|
.pop-enter-active { transition: all 0.2s ease; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user