一键导入
ta-ui-polish
UI and visual polish checklist for game presentation. Use when adding final polish, styling, animations, visual feedback.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
UI and visual polish checklist for game presentation. Use when adding final polish, styling, animations, visual feedback.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Complete Developer workflow orchestration - task research sequence, implementation flow, validation gates, PRD synchronization, exit conditions.
Complete Game Designer workflow - skill invocation protocol, GDD creation, playtest flow with GDD review, design sessions. MUST load before starting assignments.
Complete PM Coordinator workflow - task assignment, project orchestration, PRD management, worker coordination. Use proactively when starting PM agent work.
Complete PM Coordinator workflow - task assignment, project orchestration, PRD management, worker coordination. Use proactively when starting PM agent work.
Complete QA Validator workflow orchestration. References specialized skills for each validation step. Load at session startup for full protocol.
Base instructions and guidelines for all agents in the system. This skill provides foundational behaviors and communication protocols that all agents should follow.
| name | ta-ui-polish |
| description | UI and visual polish checklist for game presentation. Use when adding final polish, styling, animations, visual feedback. |
| category | ui |
"The last 10% of polish takes 90% of the time – but it's worth it."
Use when:
CRITICAL for multiplayer games: Players need to see connection state and network quality.
| State | Visual Indicator | Color | Animation |
|---|---|---|---|
| Connecting | Spinner + "Connecting..." text | Yellow | Rotate |
| Connected | Checkmark icon + ping in ms | Green | Pulse (subtle) |
| Disconnected | X icon + "Disconnected" text | Red | Blink |
| Reconnecting | Spinner + "Reconnecting..." | Orange | Rotate |
function PingIndicator({ ping }: { ping: number }) {
const getPingColor = (ping: number) => {
if (ping < 50) return '#22c55e'; // Green
if (ping < 100) return '#eab308'; // Yellow
return '#ef4444'; // Red
};
const getPingStatus = (ping: number) => {
if (ping < 50) return 'Excellent';
if (ping < 100) return 'Fair';
return 'Poor';
};
return (
<div className="ping-indicator" style={{ color: getPingColor(ping) }}>
<WifiIcon className={`animate-pulse ${ping > 100 ? 'animate-ping' : ''}`} />
<span>{ping}ms</span>
<span className="status">{getPingStatus(ping)}</span>
</div>
);
}
// ConnectionStatus.tsx
import { useEffect, useState } from 'react';
import { useConnectionStore } from '@/store/connectionStore';
export function ConnectionStatus() {
const { connected, connecting, roomId } = useConnectionStore();
const [ping, setPing] = useState(0);
// Measure ping every second
useEffect(() => {
if (!connected) return;
const interval = setInterval(async () => {
const start = performance.now();
// Send ping to server and await response
await fetch('/api/ping');
setPing(Math.round(performance.now() - start));
}, 1000);
return () => clearInterval(interval);
}, [connected]);
if (connecting) {
return (
<div className="connection-status connecting">
<Spinner className="animate-spin" />
<span>Connecting...</span>
</div>
);
}
if (!connected) {
return (
<div className="connection-status disconnected">
<XIcon className="animate-pulse" />
<span>Disconnected</span>
</div>
);
}
const pingColor = ping < 50 ? 'green' : ping < 100 ? 'yellow' : 'red';
return (
<div className="connection-status connected">
<CheckIcon className="text-green-500" />
<span>Connected</span>
<span className={`ping ping-${pingColor}`}>{ping}ms</span>
</div>
);
}
┌─────────────────────────────────────────────────────┐
│ [Connection: ● 45ms] │
│ Health: 100█████████████ │
│ Armor: 50█████ │
│ [Alive: 42/64] │
└─────────────────────────────────────────────────────┘
| Icon | When to Use | Styling |
|---|---|---|
| Spinner | Connecting, Reconnecting | animate-spin rotation |
| Checkmark | Connected | Green, subtle pulse |
| X mark | Disconnected | Red, animate-ping blink |
| Warning | High ping (>100ms) | Yellow, animate-pulse |
| Wifi bars | Signal strength | 3-4 bars based on ping |
import { useState } from 'react';
import { html } from '@react-three/drei';
import { motion, AnimatePresence } from 'framer-motion';
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
loading?: boolean;
}
export function PolishedButton({
label,
onClick,
variant = 'primary',
disabled = false,
loading = false,
}: ButtonProps) {
const [isHovered, setIsHovered] = useState(false);
const baseStyles = {
padding: '12px 24px',
borderRadius: '8px',
fontWeight: 600,
fontSize: '16px',
border: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: disabled ? 0.5 : 1,
};
const variantStyles = {
primary: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
boxShadow: isHovered ? '0 8px 20px rgba(102, 126, 234, 0.4)' : '0 4px 10px rgba(102, 126, 234, 0.3)',
transform: isHovered ? 'translateY(-2px)' : 'translateY(0)',
},
secondary: {
background: 'white',
color: '#667eea',
border: '2px solid #667eea',
boxShadow: isHovered ? '0 4px 15px rgba(102, 126, 234, 0.2)' : 'none',
},
danger: {
background: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
color: 'white',
boxShadow: isHovered ? '0 8px 20px rgba(245, 87, 108, 0.4)' : '0 4px 10px rgba(245, 87, 108, 0.3)',
},
};
return (
<motion.button
style={{ ...baseStyles, ...variantStyles[variant] }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={!disabled && !loading ? onClick : undefined}
whileTap={{ scale: disabled || loading ? 1 : 0.95 }}
disabled={disabled || loading}
>
<AnimatePresence mode="wait">
{loading ? (
<motion.span
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<LoadingSpinner />
</motion.span>
) : (
<motion.span
key="label"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{label}
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
}
<mesh
onPointerOver={(e) => {
e.object.scale.setScalar(1.1);
document.body.style.cursor = 'pointer';
}}
onPointerOut={(e) => {
e.object.scale.setScalar(1.0);
document.body.style.cursor = 'default';
}}
>
<boxGeometry />
<meshStandardMaterial color="orange" />
</mesh>
function InteractiveMesh() {
const meshRef = useRef<THREE.Mesh>(null);
const handleClick = () => {
// Scale animation
if (meshRef.current) {
gsap.to(meshRef.current.scale, {
x: 1.2,
y: 1.2,
z: 1.2,
duration: 0.1,
yoyo: true,
repeat: 1,
});
}
};
return (
<mesh ref={meshRef} onClick={handleClick}>
<sphereGeometry />
<meshStandardMaterial color="blue" />
</mesh>
);
}
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="progress-container">
<div
className="progress-bar"
style={{
width: `${Math.min(100, Math.max(0, progress * 100))}%`,
transition: 'width 0.3s ease',
}}
/>
</div>
);
}
| Background | Text | Ratio | WCAG Level |
|---|---|---|---|
| #FFFFFF | #000000 | 21:1 | AAA |
| #F5F5F5 | #333333 | 12.6:1 | AAA |
| #667EEA | #FFFFFF | 4.5:1 | AA |
| #F5576C | #FFFFFF | 4.2:1 | AA |
/* Font scale - modular scale */
.text-xs { font-size: 0.75rem; } /* 12px */
.text-sm { font-size: 0.875rem; } /* 14px */
.text-base { font-size: 1rem; } /* 16px */
.text-lg { font-size: 1.125rem; } /* 18px */
.text-xl { font-size: 1.25rem; } /* 20px */
.text-2xl { font-size: 1.5rem; } /* 24px */
.text-3xl { font-size: 1.875rem; } /* 30px */
.text-4xl { font-size: 2.25rem; } /* 36px */
/* Weights */
.font-light { font-weight: 300; }
.font-normal { font-weight: 400; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
/* 8px base unit */
.space-1 { padding: 0.25rem; } /* 4px */
.space-2 { padding: 0.5rem; } /* 8px */
.space-3 { padding: 0.75rem; } /* 12px */
.space-4 { padding: 1rem; } /* 16px */
.space-5 { padding: 1.25rem; } /* 20px */
.space-6 { padding: 1.5rem; } /* 24px */
.space-8 { padding: 2rem; } /* 32px */
.space-10 { padding: 2.5rem; } /* 40px */
.space-12 { padding: 3rem; } /* 48px */
❌ DON'T:
✅ DO:
Before considering visual polish complete:
For post-processing polish: Skill("ta-vfx-postfx")
Lesson from feat-027 (Star Rating Preview): Phaser UI animations use tweens with real-time data updates.
// src/ui/HUD.ts - Star rating preview with live score tracking
export class HUD extends Phaser.GameObjects.Container {
private stars: Phaser.GameObjects.Image[] = [];
private currentScore: number = 0;
updateStarRating(score: number, level: number): void {
// Calculate thresholds per DEC-004: 2-star = 32,000 + (level × 2,000)
const twoStarThreshold = 32000 + (level * 2000);
const threeStarThreshold = 66000 + (level * 6000);
// Determine star count from current score
const starCount = this.calculateStarCount(score, twoStarThreshold, threeStarThreshold);
// Update each star's visual state
for (let i = 0; i < 3; i++) {
const shouldFill = i < starCount;
this.updateStarFill(this.stars[i], shouldFill);
}
}
private updateStarFill(star: Phaser.GameObjects.Image, shouldFill: boolean): void {
const targetColor = shouldFill ? 0xffcc00 : 0xcccccc; // Yellow or gray
const targetAlpha = shouldFill ? 1.0 : 0.3;
// Smooth tween for color interpolation
this.scene.tweens.add({
targets: star,
duration: 300,
ease: Phaser.Math.Easing.Quadratic.Out,
props: {
tint: targetColor,
alpha: targetAlpha,
scale: shouldFill ? 1.1 : 1.0 // Subtle pulse on fill
},
yoyo: false,
repeat: 0
});
}
}
| Technique | When to Use | Duration | Easing |
|---|---|---|---|
| Color interpolation | Status changes (filled/empty) | 300ms | Quadratic.Out |
| Scale pulse | Highlight/attention effect | 200ms | Sine.easeInOut |
| Fade in/out | Element reveal | 400ms | Cubic.Out |
| Slide | Position transitions | 250ms | Back.Out |
Batch tween updates - Don't create tweens on every frame
// WRONG - Tweens on every update call
update() {
this.star.setFillStyle(this.getColor(score)); // Creates new tween each frame
}
// RIGHT - Only tween on state change
private lastStarCount: number = -1;
update() {
const newCount = this.calculateStars(score);
if (newCount !== this.lastStarCount) {
this.animateStarChange(newCount); // Only tween when changed
this.lastStarCount = newCount;
}
}
Reuse tween targets - Destroy old tweens before creating new ones
// Prevent tween accumulation
updateStarFill(star: Phaser.GameObjects.Image): void {
// Kill existing tweens on this object
this.scene.tweens.killTweensOf(star);
// Create new tween
this.scene.tweens.add({
targets: star,
// ... config
});
}
Use appropriate easing for UI
Quadratic.Out - Fast start, smooth end (good for fills)Cubic.Out - Smooth throughout (good for transitions)Sine.easeInOut - Gentle in/out (good for pulses)Back.Out - Subtle overshoot (good for button clicks)Learned from ui-001 playtest: Current UI lacked professional polish. This section documents patterns for shipping-quality game UI.
Pattern: All UI screens must maintain 16:9 aspect ratio, centered on screen with letterbox bars for other ratios.
// src/components/ui/AspectContainer.tsx
const ASPECT_RATIO = 16 / 9;
export function AspectContainer({ children }: { children: React.ReactNode }) {
return (
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black">
<div
className="relative overflow-hidden"
style={{
aspectRatio: ASPECT_RATIO,
maxWidth: '100vw',
maxHeight: '100vh',
width: 'min(100vw, 100vh * 16/9)',
height: 'min(100vh, 100vw * 9/16)',
}}
>
{children}
</div>
</div>
);
}
Letterbox CSS:
/* Letterbox bars for non-16:9 displays */
.ui-letterbox-top,
.ui-letterbox-bottom {
position: fixed;
left: 0; right: 0;
height: calc((100vh - (100vw * 9 / 16)) / 2);
background: #000;
z-index: 50;
}
.ui-letterbox-top { top: 0; }
.ui-letterbox-bottom { bottom: 0; }
@media (min-aspect-ratio: 16/9) {
.ui-letterbox-top, .ui-letterbox-bottom { display: none; }
}
// src/components/ui/tokens.ts
export const UIScale = {
base: { width: 1920, height: 1080 },
getScaleFactor(windowWidth: number, windowHeight: number): number {
const baseWidth = 1920;
const scale = Math.min(
windowWidth / baseWidth,
windowHeight / (baseWidth * 9 / 16)
);
return Math.max(0.5, Math.min(2, scale));
},
fontSize: {
xs: '14px', // 0.875rem
sm: '16px', // 1rem
base: '18px', // 1.125rem
lg: '24px', // 1.5rem
xl: '32px', // 2rem
'2xl': '48px', // 3rem
'3xl': '64px', // 4rem
display: '128px', // 8rem
},
};
Inspired by: Quake III Arena, modern Call of Duty, Overwatch
// Metallic gradient backgrounds
const metallicBackgrounds = {
primary: `linear-gradient(180deg,
rgba(249, 115, 22, 0.1) 0%,
rgba(0, 0, 0, 0.9) 20%,
rgba(0, 0, 0, 1) 50%,
rgba(0, 0, 0, 0.9) 80%,
rgba(249, 115, 22, 0.1) 100%
)`,
secondary: `linear-gradient(180deg,
rgba(38, 38, 38, 0.9) 0%,
rgba(10, 10, 10, 1) 50%,
rgba(38, 38, 38, 0.9) 100%
)`,
};
// Hover glow effects
const metallicGlow = {
hover: `box-shadow:
0 0 20px rgba(251, 115, 22, 0.4),
inset 0 0 20px rgba(251, 115, 22, 0.1)`,
active: `box-shadow:
0 0 40px rgba(251, 115, 22, 0.6),
inset 0 0 30px rgba(251, 115, 22, 0.2)`,
};
Sources: Motion.dev Easing, The Easing Blueprint
// Easing curve library for game UI
export const GameEasing = {
// Snappy entrance (menu reveal)
snap: [0.22, 1, 0.36, 1] as const,
// Smooth reveal (screen transitions)
reveal: [0.16, 1, 0.3, 1] as const,
// Elastic bounce (button release)
bounce: [0.34, 1.56, 0.64, 1] as const,
// Weighty press (button click)
press: [0.4, 0, 0.2, 1] as const,
};
// Animation timing specifications
const buttonAnimations = {
idleEnter: { duration: 400, ease: GameEasing.snap },
hover: { duration: 150, ease: GameEasing.press },
active: { duration: 50, ease: GameEasing.press },
release: { duration: 300, ease: GameEasing.bounce },
};
/* Import Google Fonts for game UI */
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@700;900&family=Rajdhani:wght@500;600;700&display=swap');
:root {
--font-display: 'Orbitron', 'Impact', sans-serif;
--font-ui: 'Rajdhani', 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
}
| Usage | Font | Size | Weight | Letter Spacing | Transform |
|---|---|---|---|---|---|
| Game Logo | Orbitron | 128px | 900 | 0.05em | Uppercase |
| Screen Title | Orbitron | 64px | 700 | 0.1em | Uppercase |
| Button Text | Rajdhani | 24px | 600 | 0.15em | Uppercase |
| Body Text | Rajdhani | 18px | 500 | 0.02em | None |
| Data/Stats | JetBrains Mono | 16px | 500 | 0 | None |
:root {
/* Primary - Electric Orange (Quake-inspired) */
--color-primary-500: #f97316;
--color-primary-600: #ea580c;
/* Secondary - Cyan/Blue */
--color-secondary-500: #06b6d4;
/* Metallic Grays */
--color-metal-900: #0a0a0a;
--color-metal-700: #262626;
--color-metal-500: #525252;
/* Surface Colors */
--color-surface-bg: rgba(10, 10, 10, 0.95);
--color-surface-border: rgba(251, 115, 22, 0.3);
/* Glow Effects */
--color-glow-primary: rgba(249, 115, 22, 0.6);
}
Before considering UI polished: