canvas-水墨1

3 models · 百模竞速 · TOKRACE · share snapshot
Share and embed

Post to social channels, or use Markdown and badges for GitHub/README.

XFacebook微博LinkedIn
[![canvas-水墨1 speed result on TOKRACE](/api/badge/share/G1KoxeQZAp?locale=en)](/en/r/G1KoxeQZAp)
写一个单文件 HTML(只输出完整代码,不要解释),用原生 Canvas 实现一部智能手机锁屏解锁的动画。展示一个手机屏幕,柔和渐变壁纸上有大号时钟与日期,底部有一条带可拖动滑块的「滑动解锁」轨道。一道流光高亮应持续在「滑动解锁」文字上扫过(移动的高亮渐变)。随后滑块一路滑到底;完成时锁屏平滑地滑出/淡出,露出主屏——一组圆角 App 图标(原创设计)轻柔地缩放并淡入就位,壁纸有细微视差。整体使用精致、流畅、Apple 级的缓动。60fps,不要使用外部库。
Rerun this testFull rerun carries title, notes, Prompt and models; Prompt rerun only reuses the task text.
Visible
🏁 Live racereasoning / outputspeed t/s · ref 300
智谱 GLM-5.2
🏁
61168
83 t/s
Kimi 月之暗面-high-think-budget
🏁
18941
236 t/s
阶跃星辰
0
🏁 Verdict
🏆 Fastest
Kimi 月之暗面-high-think-budget
236 tok/s
💰 Cheapest
Kimi 月之暗面-high-think-budget
≈$0.076
Recommended
Kimi 月之暗面-high-think-budget
fastest · cheapest
阶跃星辰
step-3.7-flash
Failed
fetch failed
TTFT
Reasoning TPS
Output TPS
Total Tokens
Kimi 月之暗面-high-think-budget🥇
kimi-for-coding
Done
We need create a single file HTML with Canvas rendering phone lock screen unlock animation. Need produce complete code only, no explanation. Must be 60fps, native Canvas, no external libs. Needs: smartphone screen, soft gradient wallpaper, large clock and date, bottom slider track with draggable knob, "滑动解锁" text, shimmering highlight sweep over text continuously (moving gradient highlight). Then user drags slider to end; when complete lock screen smoothly slides out/fades revealing home screen with rounded app icons original design gently scale and fade in, wallpaper subtle parallax. Smooth Apple-like easing. Need think through design: Canvas fills viewport or centered? "智能手机" screen maybe canvas inside a phone frame. Could draw phone bezel around screen. Need handle high-DPI for crispness. Use requestAnimationFrame. State machine: lockscreen, unlocking (drag), transitioning to home, home. Need clock real time, date in Chinese. Slider: touch/mouse events on canvas coordinates. Detect drag on knob. Track at bottom of screen. Text "滑动解锁" above or on track. Shimmer: use gradient offset oscillating. Lock screen completion threshold at right end of track. When complete, animate lock screen group translateY/opacity to reveal home screen. Home icons grid, original designs? Need draw custom icon shapes/colors with rounded rects and symbols. Wallpaper: soft gradient; parallax offset based on maybe mouse/touch position and during transition. Also maybe animated subtle movement based on time. Need ensure 60fps. Use devicePixelRatio, set canvas size to CSS size times DPR, scale context. Resize handling. Let's design dimensions. Phone screen coordinate logical width W=375, height H=812 (iPhone X ratio). Place canvas in center with CSS max height 90vh and aspect ratio. Internal logic uses fixed logical coordinate system, scaling from actual canvas dimensions. Draw rounded phone frame and notch? Could add rounded corners and notch for realism. Define colors: lock screen wallpaper: radial gradient from soft pink/orange to blue? Home screen wallpaper: different soft gradient, maybe blue/purple. But prompt says "柔和渐变壁纸" maybe same wallpaper on both? Lock screen and home screen can share similar but maybe home has icons. Use gradients, animated maybe slight hue shift? Keep subtle. Lock screen elements: - Large digital clock: HH:MM, font 90, weight 200, white, shadow. - Date: weekday, month day, e.g. "10月24日 星期四", font 20. - Top status bar: time, signal, wifi, battery (optional). - Bottom slider track: rounded pill at y ~720, width 300, height 56, x centered. Track fill rgba white 0.15, stroke white 0.3. Knob circle at left + padding, radius 22, white with shadow. Text "滑动解锁" centered on track, white 0.8 alpha, font 17. Shimmer: clip to track area, draw text with gradient fill that moves. Or easier: draw text normally then overlay with gradient mask? Need shimmer highlight sweeping over text. Approach: draw text with globalCompositeOperation 'source-in'? We can draw text on track area as a transparent layer? Let's implement a helper drawShimmerText(ctx, text, x, y, width, progressTime): fill text with gradient where gradient stop positions move. Use ctx.fillStyle = linear gradient across width, with transparent white and bright white stops moving. That gives shimmer. But text may be partially outside track; fine. Need ensure text above knob? Knob slides over text; knob on top. Draw text before knob. Drag behavior: knob x constrained from startX+knobR to endX-knobR. Unlock at >= endX - knobR - threshold maybe at 90% end. On release before threshold, animate knob back to start with spring ease. On reaching end, trigger unlock transition. Unlock transition: Lock screen group translate upward and fade; simultaneously home screen icons animate in. Could have lock screen slide up and fade out over 0.6s. Home icons: grid 4 columns, 6 rows. Each icon starts scaled 0.8 opacity 0, then scales to 1 with stagger and opacity. Use spring/elastic? Apple-like ease out back maybe slight. Wallpaper parallax: based on normalized mouse/touch position relative to center, offset background layer by small amount; during transition also perhaps zoom slightly. Home screen includes dock at bottom with 4 icons. We must produce single file complete HTML. Use style minimal: body dark, canvas centered. JS in script. Need high-DPI: set canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; ctx.scale(dpr*scaleToLogical, dpr*scaleToLogical)? Better define logical coordinates and scale once to logical->physical. Suppose logical width logicalW = 390 (phone screen width). Determine scale so that actual canvas width = cssWidth. Let cssWidth = window.innerWidth*0.9 maybe; cssHeight = min(window.innerHeight*0.9, cssWidth*812/375). Then dpr = window.devicePixelRatio||1. Set canvas.width = cssW*dpr, canvas.height=cssH*dpr. ctx.setTransform(dpr*cssW/logicalW, 0, 0, dpr*cssH/logicalH, 0,0). But we also draw phone frame beyond screen? Maybe logical coordinate includes phone frame. Let's define logicalW=420, logicalH=852 (375+45, 812+40? Actually frame). The screen area starts at x=(logicalW-375)/2=22.5, y=20? For notch etc. Better set screenW=375, screenH=812, framePadding=25, topBar=20? Phone frame total logicalW=425, logicalH=852. Canvas transform maps to logical. Simpler: use logicalW=390, logicalH=844 (iPhone ratio). Let frame padding=20? Hmm. Alternative: Keep logical coordinate equal to screen; draw phone frame around. Canvas actual size based on CSS. Use scale factor = cssW / screenW. Set transform dpr*scale. Then content drawn in screen coordinates. But phone frame extends beyond canvas if fixed aspect. We can make canvas logical size include frame, and CSS sized to total phone frame. Let's choose logicalW=430, logicalH=880. Screen rect: x=27.5, y=34? Frame radius 55, thickness 12. Notch at top. We'll compute. Need ensure touch/mouse coordinates mapping: getBoundingClientRect, compute cssX/cssY, then logicalX = cssX * logicalW / rect.width; logicalY = cssY * logicalH / rect.height. State variables: - sliderKnobX = trackStart + knobRadius + margin - sliderDragging = false - unlockProgress = 0..1 for transition - homeIconAnimation = 0..1 time - mouse pointer for parallax: targetPointer {x,y}, currentPointer smooth. Clock: update each second? Could display per frame but seconds not shown; date not change. Icons: Need original designs. Draw app icons as rounded squares (76x76 radius 18) with gradient backgrounds and simple symbols: Messages (green gradient, speech bubble), Phone (blue, phone handset), Mail (blue/white envelope), Photos (colorful pinwheel), Music (red/orange, music note), Maps (map road), Weather (sun/cloud), Settings (gears), Calendar (red top, date), Clock (black, clock face), Notes (yellow, lines), Camera (gray, camera). Home grid maybe 4x6. Place icon center positions in screen coords. Need staggered appear: row by row maybe each icon animate with delay based on index. Parallax: home wallpaper offset by pointer relative to center, factor maybe 15px. Also lock screen wallpaper same offset but smaller? Prompt says wallpaper has subtle parallax, maybe both. Use smooth lerp. Shimmer: use a time variable; gradient stops moving. We'll make text gradient fill: gradient from left with stops: transparent 0, white 0.3, transparent 0.5, white 0.7, transparent 1, moving. Or simpler a bright band sweeping across. Use gradient with two highlights. Let's design precise slider: - track x = screenX + (screenW - trackW)/2 = 37.5? screenX=27.5, screenW=375, trackW=320, so trackX=55. - trackY = screenY + screenH - bottomPadding = 34 + 812 - 110 = 736. - trackH=54, trackW=320, radius=trackH/2. - knob radius = 23, knobX min=trackX+trackH/2? Actually knob stays inside track: x ranges from trackX+knobR+padding to trackX+trackW-knobR-padding. Let padding=4. So minX=trackX+27, maxX=trackX+trackW-27. Unlock threshold = maxX - maybe 5. - Text centered on track. Interactions: on pointer d
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>滑动解锁动画</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body {
    width: 100%; height: 100%;
    background: #0d0d0d;
    overflow: hidden;
    display: flex; align-items: center; justify-content: center;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  }
  canvas {
    display: block;
    border-radius: 56px;
    box-shadow: 0 40px 100px rgba(0,0,0,0.6), 0 0 0 2px rgba(255,255,255,0.06);
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
  }
</style>
</head>
<body>
<canvas id="screen"></canvas>
<script>
(function(){
  const canvas = document.getElementById('screen');
  const ctx = canvas.getContext('2d');
  const dpr = window.devicePixelRatio || 1;

  const W = 430, H = 880;
  const pad = 27;
  const sx = pad, sy = pad, sw = W-pad*2, sh = H-pad*2;
  const cx = sx + sw/2, cy = sy + sh/2;

  let cssW, cssH;
  function resize(){
    const aspect = W/H;
    const maxW = window.innerWidth * 0.95;
    const maxH = window.innerHeight * 0.95;
    cssW = maxW; cssH = cssW/aspect;
    if(cssH > maxH){ cssH = maxH; cssW = cssH*aspect; }
    canvas.style.width = cssW+'px';
    canvas.style.height = cssH+'px';
    canvas.width = Math.floor(cssW*dpr);
    canvas.height = Math.floor(cssH*dpr);
    ctx.setTransform(canvas.width/W, 0, 0, canvas.height/H, 0, 0);
  }
  resize();
  window.addEventListener('resize', resize);

  const STATE_LOCK = 0, STATE_UNLOCKING = 1, STATE_HOME = 2;
  let state = STATE_LOCK;

  const trackW = 320, trackH = 56;
  const trackX = sx + (sw-trackW)/2;
  const trackY = sy + sh - 130;
  const knobR = 23;
  const minX = trackX + trackH/2;
  const maxX = trackX + trackW - trackH/2;
  let knobX = minX;
  let dragging = false;

  let targetPX = 0, targetPY = 0, curPX = 0, curPY = 0;

  let unlockStart = 0, iconStart = 0;
  const unlockDur = 650, iconDur = 900;
  let unlockT = 0, iconT = 0;

  function easeOutCubic(t){ return 1 - Math.pow(1-t,3); }
  function easeOutQuint(t){ return 1 - Math.pow(1-t,5); }
  function clamp(v,a,b){ return Math.max(a,Math.min(b,v)); }

  function roundedRect(ctx,x,y,w,h,r){
    const rr = Math.min(r,w/2,h/2);
    ctx.beginPath();
    ctx.moveTo(x+rr,y);
    ctx.lineTo(x+w-rr,y);
    ctx.quadraticCurveTo(x+w,y,x+w,y+rr);
    ctx.lineTo(x+w,y+h-rr);
    ctx.quadraticCurveTo(x+w,y+h,x+w-rr,y+h);
    ctx.lineTo(x+rr,y+h);
    ctx.quadraticCurveTo(x,y+h,x,y+h-rr);
    ctx.lineTo(x,y+rr);
    ctx.quadraticCurveTo(x,y,x+rr,y);
    ctx.closePath();
  }

  function toScreen(x,y){
    const rect = canvas.getBoundingClientRect();
    return {
      x: (x - rect.left) * W / rect.width,
      y: (y - rect.top) * H / rect.height
    };
  }

  canvas.addEventListener('pointerdown', e => {
    if(state !== STATE_LOCK) return;
    const p = toScreen(e.clientX, e.clientY);
    targetPX = (p.x - cx)/(sw/2); targetPY = (p.y - cy)/(sh/2);
    if(Math.hypot(p.x - knobX, p.y - (trackY+trackH/2)) < knobR + 14){
      dragging = true;
      knobX = clamp(p.x, minX, maxX);
      canvas.setPointerCapture(e.pointerId);
    }
  });
  canvas.addEventListener('pointermove', e => {
    const p = toScreen(e.clientX, e.clientY);
    targetPX = clamp((p.x - cx)/(sw/2), -1, 1);
    targetPY = clamp((p.y - cy)/(sh/2), -1, 1);
    if(dragging){
      knobX = clamp(p.x, minX, maxX);
      if(knobX >= maxX - 2) startUnlock();
    }
  });
  canvas.addEventListener('pointerup', e => {
    if(dragging){
      dragging = false;
      if(state === STATE_LOCK) canvas.releasePointerCapture(e.pointerId);
    }
  });

  function startUnlock(){
    if(state !== STATE_LOCK) return;
    state = STATE_UNLOCKING;
    dragging = false;
    knobX = maxX;
    const now = performance.now();
    unlockStart = now;
    iconStart = now + 120;
  }

  const lockBlobs = [
    {x:0.2,y:0.2,r:230,c:'rgba(255,236,210,0.5)'},
    {x:0.75,y:0.35,r:250,c:'rgba(252,182,159,0.4)'},
    {x:0.45,y:0.8,r:270,c:'rgba(161,140,209,0.4)'},
    {x:0.1,y:0.9,r:190,c:'rgba(255,255,255,0.25)'}
  ];
  const homeBlobs = [
    {x:0.3,y:0.2,r:240,c:'rgba(194,233,251,0.55)'},
    {x:0.7,y:0.5,r:260,c:'rgba(161,196,253,0.45)'},
    {x:0.2,y:0.7,r:220,c:'rgba(251,194,235,0.4)'},
    {x:0.85,y:0.85,r:200,c:'rgba(255,255,255,0.35)'}
  ];

  function drawWallpaper(ctx, isHome, px, py){
    const g = ctx.createLinearGradient(sx-px, sy-py, sx+sw-px, sy+sh-py);
    if(isHome){
      g.addColorStop(0,'#c2e9fb'); g.addColorStop(0.5,'#a1c4fd'); g.addColorStop(1,'#fbc2eb');
    } else {
      g.addColorStop(0,'#ffecd2'); g.addColorStop(0.5,'#fcb69f'); g.addColorStop(1,'#a18cd1');
    }
    ctx.fillStyle = g;
    ctx.fillRect(sx, sy, sw, sh);
    const blobs = isHome ? homeBlobs : lockBlobs;
    blobs.forEach(b => {
      const bx = sx + b.x*sw - px, by = sy + b.y*sh - py;
      const rg = ctx.createRadialGradient(bx, by, 0, bx, by, b.r);
      rg.addColorStop(0, b.c);
      rg.addColorStop(1, 'rgba(255,255,255,0)');
      ctx.fillStyle = rg;
      ctx.beginPath(); ctx.arc(bx, by, b.r, 0, Math.PI*2); ctx.fill();
    });
  }

  function pad2(n){ return n<10?'0'+n:''+n; }

  function drawStatusBar(ctx, withSeconds){
    const now = new Date();
    const t = pad2(now.getHours()) + ':' + pad2(now.getMinutes()) + (withSeconds ? ':' + pad2(now.getSeconds()) : '');
    ctx.fillStyle = 'rgba(255,255,255,0.95)';
    ctx.font = '600 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
    ctx.fillText(t, sx+24, sy+24);

    ctx.strokeStyle = 'rgba(255,255,255,0.9)';
    ctx.lineWidth = 1.4;
    const bx = sx+sw-36, by = sy+11, bw=24, bh=12;
    ctx.strokeRect(bx, by, bw, bh);
    ctx.fillStyle = 'rgba(255,255,255,0.9)';
    ctx.fillRect(bx+bw+1.5, by+3.5, 2, bh-7);
    ctx.fillRect(bx+2.5, by+2.5, (bw-5)*0.72, bh-5);

    for(let i=0;i<4;i++){
      ctx.fillRect(sx+sw-82+i*5, sy+14+(3-i)*2, 3.5, 10-(3-i)*2);
    }
  }

  function drawSlider(ctx, now){
    roundedRect(ctx, trackX, trackY, trackW, trackH, trackH/2);
    ctx.fillStyle = 'rgba(255,255,255,0.13)';
    ctx.fill();
    ctx.lineWidth = 1;
    ctx.strokeStyle = 'rgba(255,255,255,0.22)';
    ctx.stroke();

    ctx.font = '500 17px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    const grad = ctx.createLinearGradient(trackX, 0, trackX+trackW, 0);
    const phase = (now * 0.00045) % 1.6;
    const pos = phase - 0.3;
    function addStop(p,c){ if(p>=0 && p<=1) grad.addColorStop(p,c); }
    addStop(0, 'rgba(255,255,255,0.4)');
    addStop(pos-0.12, 'rgba(255,255,255,0.4)');
    addStop(pos, 'rgba(255,255,255,1)');
    addStop(pos+0.12, 'rgba(255,255,255,0.4)');
    addStop(1, 'rgba(255,255,255,0.4)');
    ctx.fillStyle = grad;
    ctx.fillText('滑动解锁', trackX+trackW/2, trackY+trackH/2);

    ctx.shadowColor = 'rgba(0,0,0,0.25)';
    ctx.shadowBlur = 12; ctx.shadowOffsetY = 4;
    ctx.beginPath(); ctx.arc(knobX, trackY+trackH/2, knobR, 0, Math.PI*2);
    ctx.fillStyle = '#ffffff'; ctx.fill();
    ctx.shadowColor = 'transparent';
  }

  function drawLockScreen(ctx, now, alpha, slide){
    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.translate(0, -slide);
    drawWallpaper(ctx, false, curPX*8, curPY*8);
    drawStatusBar(ctx, true);

    const d = new Date();
    const timeStr = pad2(d.getHours()) + ':' + pad2(d.getMinutes());
    const dateStr = d.toLocaleDateString('zh-CN', {month:'long', day:'numeric', weekday:'long'});

    ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';
    ctx.fillStyle = 'rgba(255,255,255,0.96)';
    ctx.shadowColor = 'rgba(0,0,0,0.25)';
    ctx.shadowBlur = 18; ctx.shadowOffsetY = 4;
    ctx.font = '200 88px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    ctx.fillText(timeStr, cx, sy+170);
    ctx.shadowBlur = 10;
    ctx.font = '400 22px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
    ctx.fillText(dateStr, cx, sy+210);
    ctx.shadowColor = 'transparent';

    drawSlider(ctx, now);
    ctx.restore();
  }

  function drawPhone(ctx,s){
    ctx.strokeStyle='white'; ctx.lineWidth=s*0.08; ctx.lineCap='round'; ctx.lineJoin='round';
    ctx.beginPath();
    ctx.moveTo(-s*0.22,-s*0.18);
    ctx.quadraticCurveTo(s*0.18,-s*0.28, s*0.18,s*0.18);
    ctx.quadraticCurveTo(s*0.18,s*0.28, s*0.08,s*0.28);
    ctx.quadraticCurveTo(s*0.08,s*0.28, s*0.08,s*0.18);
    ctx.quadraticCurveTo(s*0.08,-s*0.12, -s*0.22,-s*0.08);
    ctx.closePath(); ctx.stroke();
  }
  function drawMessages(ctx,s){
    ctx.fillStyle='white';
    ctx.beginPath(); ctx.ellipse(0,-s*0.04, s*0.22, s*0.17, 0, 0, Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.moveTo(s*0.1,s*0.06); ctx.lineTo(s*0.28,s*0.2); ctx.lineTo(s*0.06,s*0.14); ctx.closePath(); ctx.fill();
  }
  function drawMail(ctx,s){
    ctx.strokeStyle='white'; ctx.lineWidth=s*0.06; ctx.lineJoin='round';
    ctx.strokeRect(-s*0.25,-s*0.18,s*0.5,s*0.36);
    ctx.beginPath(); ctx.moveTo(-s*0.25,-s*0.18); ctx.lineTo(0,0); ctx.lineTo(s*0.25,-s*0.18); ctx.stroke();
  }
  function drawSafari(ctx,s){
    ctx.strokeStyle='white'; ctx.lineWidth=s*0.05;
    ctx.beginPath(); ctx.arc(0,0,s*0.22,0,Math.PI*2); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0,-s*0.22); ctx.lineTo(0,-s*0.08); ctx.stroke();
    ctx.fillStyle='white';
    ctx.beginPath(); ctx.moveTo(0,-s*0.04); ctx.lineTo(s*0.06,s*0.18); ctx.lineTo(-s*0.06,s*0.18); ctx.closePath(); ctx.fill();
  }
  function drawMusic(ctx,s){
    ctx.fillStyle='white';
    ctx.beginPath(); ctx.ellipse(-s*0.08,s*0.1,s*0.1,s*0.08,0,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.moveTo(s*0.02,-s*0.22); ctx.lineTo(s*0.22,-s*0.12); ctx.lineTo(s*0.22,s*0.08); ctx.lineTo(s*0.02,-0.02); ctx.closePath(); ctx.fill();
    ctx.fillRect(-s*0.02,-s*0.22,s*0.06,s*0.36);
  }
  function drawPhotos(ctx,s){
    const colors=['#ff453a','#ffcc00','#34c759','#0a84ff'];
    ctx.save();
    for(let i=0;i<4;i++){
      ctx.rotate(Math.PI/2);
      ctx.fillStyle=colors[i];
      ctx.beginPath();
      ctx.moveTo(0,0);
      ctx.quadraticCurveTo(s*0.18,-s*0.18, s*0.28,0);
      ctx.quadraticCurveTo(s*0.18,s*0.18,0,0);
      ctx.fill();
    }
    ctx.restore();
  }
  function drawCamera(ctx,s){
    ctx.fillStyle='white';
    roundedRect(ctx,-s*0.26,-s*0.18,s*0.52,s*0.36,s*0.06); ctx.fill();
    ctx.fillStyle='#1c1c1e'; ctx.beginPath(); ctx.arc(0,0,s*0.15,0,Math.PI*2); ctx.fill();
    ctx.fillStyle='#8e8e93'; ctx.beginPath(); ctx.arc(0,0,s*0.08,0,Math.PI*2); ctx.fill();
    ctx.fillStyle='white'; ctx.beginPath(); ctx.arc(s*0.16,-s*0.1,s*0.03,0,Math.PI*2); ctx.fill();
  }
  function drawMaps(ctx,s){
    ctx.strokeStyle='rgba(255,255,255,0.9)'; ctx.lineWidth=s*0.06; ctx.lineCap='round';
    ctx.beginPath(); ctx.moveTo(-s*0.22,-s*0.08); ctx.lineTo(s*0.05,s*0.05); ctx.lineTo(s*0.25,-s*0.18); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(-s*0.12,s*0.25); ctx.lineTo(s*0.05,s*0.05); ctx.lineTo(s*0.14,s*0.25); ctx.stroke();
  }
  function drawCalendar(ctx,s){
    ctx.fillStyle='white'; roundedRect(ctx,-s*0.25,-s*0.25,s*0.5,s*0.5,s*0.08); ctx.fill();
    ctx.fillStyle='#ff3b30'; ctx.fillRect(-s*0.25,-s*0.25,s*0.5,s*0.18);
    ctx.fillStyle='#1c1c1e'; ctx.font=`bold ${s*0.35}px -apple-system, sans-serif`;
    ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('24',0,s*0.08);
  }
  function drawClock(ctx,s){
    ctx.strokeStyle='white'; ctx.lineWidth=s*0.05; ctx.lineCap='round';
    ctx.beginPath(); ctx.arc(0,0,s*0.2,0,Math.PI*2); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-s*0.13); ctx.stroke();
    ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(s*0.11,s*0.06); ctx.stroke();
  }
  function drawWeather(ctx,s){
    ctx.fillStyle='#ffcc00'; ctx.beginPath(); ctx.arc(-s*0.06,-s*0.08,s*0.13,0,Math.PI*2); ctx.fill();
    ctx.fillStyle='white';
    ctx.beginPath(); ctx.arc(s*0.08,-s*0.02,s*0.1,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(-s*0.02,s*0.04,s*0.1,0,Math.PI*2); ctx.fill();
    ctx.beginPath(); ctx.arc(s*0.18,s*0.04,s*0.08,0,Math.PI*2); ctx.fill();
  }
  function drawNotes(ctx,s){
    ctx.fillStyle='rgba(255,255,255,0.92)';
    for(let i=0;i<4;i++) ctx.fillRect(-s*0.2,-s*0.16+i*s*0.11,s*0.4,s*0.03);
  }
  function drawSettings(ctx,s){
    ctx.strokeStyle='white'; ctx.lineWidth=s*0.06; ctx.lineJoin='round';
    ctx.beginPath();
    for(let i=0;i<10;i++){
      const a=i*Math.PI/5, r1=s*0.12, r2=s*0.2;
      ctx.lineTo(Math.cos(a)*r2, Math.sin(a)*r2);
      ctx.lineTo(Math.cos(a+Math.PI/10)*r1, Math.sin(a+Math.PI/10)*r1);
    }
    ctx.closePath(); ctx.stroke();
    ctx.beginPath(); ctx.arc(0,0,s*0.06,0,Math.PI*2); ctx.stroke();
  }

  const iconSize = 68, gap = 23, leftMargin = (sw - (iconSize*4 + gap*3))/2;
  const gridTop = sy + 110, rowGap = 90;
  const gridApps = [
    {name:'电话', bg:['#34c759','#30b350'], draw:drawPhone},
    {name:'信息', bg:['#34c759','#30b350'], draw:drawMessages},
    {name:'邮件', bg:['#0a84ff','#0066d4'], draw:drawMail},
    {name:'浏览器', bg:['#0a84ff','#0066d4'], draw:drawSafari},
    {name:'音乐', bg:['#ff2d55','#ff5e3a'], draw:drawMusic},
    {name:'照片', bg:['#ffffff','#e5e5ea'], draw:drawPhotos},
    {name:'相机', bg:['#8e8e93','#636366'], draw:drawCamera},
    {name:'地图', bg:['#34c759','#30b350'], draw:drawMaps},
    {name:'日历', bg:['#ffffff','#f2f2f7'], draw:drawCalendar},
    {name:'时钟', bg:['#1c1c1e','#000000'], draw:drawClock},
    {name:'天气', bg:['#0a84ff','#5ac8fa'], draw:drawWeather},
    {name:'备忘录', bg:['#ffcc00','#ff9500'], draw:drawNotes}
  ];
  gridApps.forEach((a,i)=>{ a.delay = i*0.04; });
  const dockApps = [gridApps[0], gridApps[1], gridApps[2], gridApps[3]];
  dockApps.forEach((a,i)=>{ a.dockDelay = 0.25 + i*0.05; });

  function drawAppIcon(ctx, ix, iy, s, app, animT, isDock){
    const delay = isDock ? app.dockDelay : app.delay;
    let t = (animT - delay) / 0.55;
    t = clamp(t,0,1);
    if(t<=0) return;
    const e = easeOutQuint(t);
    const alpha = e;
    const sc = 0.72 + 0.28*e;
    ctx.save();
    ctx.globalAlpha = alpha;
    ctx.translate(ix, iy);
    ctx.scale(sc, sc);
    const g = ctx.createRadialGradient(-s*0.3,-s*0.3,0,0,0,s*0.9);
    g.addColorStop(0, app.bg[0]); g.addColorStop(1, app.bg[1]);
    roundedRect(ctx,-s/2,-s/2,s,s,16); ctx.fillStyle=g; ctx.fill();
    ctx.save();
    app.draw(ctx,s);
    ctx.restore();
    ctx.restore();
  }

  function drawHomeScreen(ctx, now){
    drawWallpaper(ctx, true, curPX*12, curPY*12);
    drawStatusBar(ctx, true);

    const dockY = sy+sh-105, dockH = 92;
    ctx.fillStyle = 'rgba(255,255,255,0.22)';
    roundedRect(ctx, sx+10, dockY, sw-20, dockH, 28); ctx.fill();

    for(let i=0;i<gridApps.length;i++){
      const row = Math.floor(i/4), col = i%4;
      const x = sx + leftMargin + col*(iconSize+gap) + iconSize/2;
      const y = gridTop + row*rowGap + iconSize/2;
      drawAppIcon(ctx, x, y, iconSize, gridApps[i], iconT, false);
    }
    const dockGap = (sw-20 - iconSize*4)/5;
    for(let i=0;i<4;i++){
      const x = sx+10+dockGap+i*(iconSize+dockGap)+iconSize/2;
      drawAppIcon(ctx, x, dockY+dockH/2, iconSize, dockApps[i], iconT, true);
    }
  }

  function drawPhoneFrame(ctx){
    ctx.save();
    ctx.fillStyle = '#050505';
    roundedRect(ctx,0,0,W,H,56); ctx.fill();
    ctx.restore();

    ctx.save();
    ctx.fillStyle = '#000';
    roundedRect(ctx, W/2-75, 0, 150, 32, 14); ctx.fill();
    ctx.fillStyle = '#1a1a1a';
    roundedRect(ctx, W/2-28, 12, 56, 6, 3); ctx.fill();
    ctx.restore();
  }

  function loop(now){
    if(state === STATE_UNLOCKING){
      unlockT = clamp((now - unlockStart)/unlockDur, 0, 1);
      iconT = clamp((now - iconStart)/iconDur, 0, 1);
      if(unlockT >= 1 && iconT >= 1) state = STATE_HOME;
    } else if(state === STATE_HOME){
      unlockT = 1; iconT = 1;
    } else {
      unlockT = 0; iconT = 0;
      if(!dragging && knobX > minX + 0.5) knobX += (minX - knobX)*0.14;
    }

    curPX += (targetPX - curPX)*0.08;
    curPY += (targetPY - curPY)*0.08;

    ctx.clearRect(0,0,W,H);

    ctx.save();
    roundedRect(ctx, pad, pad, sw, sh, 44); ctx.clip();
    drawHomeScreen(ctx, now);
    ctx.restore();

    if(state !== STATE_HOME){
      const et = easeOutCubic(unlockT);
      const alpha = 1 - et;
      const slide = et * sh * 0.45;
      if(alpha > 0.001){
        ctx.save();
        roundedRect(ctx, pad, pad, sw, sh, 44); ctx.clip();
        drawLockScreen(ctx, now, alpha, slide);
        ctx.restore();
      }
    }

    drawPhoneFrame(ctx);
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>
5.16s
TTFT
208
Reasoning TPS
67.0s · ≈13,901 tok
236
Output TPS
21.3s · ≈5,040 tok
18,941
Total Tokens (official)
Input 145
Elapsed 94.4s · Average 214 tok/s · Peak 325 tok/s · Cost $0.076
智谱 GLM-5.2
glm-5.2
Done
用户要求一个单文件 HTML,实现智能手机锁屏解锁动画。让我仔细规划: **核心需求:** 1. 手机屏幕(手机外框 + 屏幕) 2. 柔和渐变壁纸 3. 大号时钟 + 日期 4. 底部"滑动解锁"轨道 + 滑块 5. 流光高亮持续在文字上扫过(移动的渐变高亮) 6. 滑块拖动到底 7. 完成时锁屏滑出/淡出 8. 主屏 App 图标缩放淡入 9. 细微视差 10. Apple 级缓动 11. 60fps 12. 纯 Canvas,无外部库 **设计思路:** 颜色方案 - 不用紫色/蓝色默认。我想用一个温暖的日落色或者清新的薄荷绿+珊瑚色。或者使用 Apple 风格的暖色调渐变(粉橙到紫红再到深蓝),但避免淡紫色。 让我选一个:使用珊瑚橙 + 玫瑰红 + 深紫红的渐变(Apple iOS 17 风格但更鲜艳)。或者用一个清新的青绿到深青色的渐变。 我决定用:**暖色调日落渐变** - 从橙黄(顶部) → 珊瑚粉(中部) → 深紫红(底部),但底部避免淡紫色,用深酒红色调。 实际上让我用更现代的方案:**极光风格** - 青绿 + 暖橙 + 玫瑰金,营造高级感。 **布局:** - 中心一台手机(圆角矩形外框) - 状态栏(时间、信号、电池) - 大时钟(占据中部) - 日期 + 星期 - 通知卡片(可选,增加丰富度) - 底部"滑动解锁"轨道 + 滑块手柄 - Home indicator **动画时序:** 1. 进入:手机从下方淡入 2. 流光:持续在"滑动解锁"文字上扫过 3. 用户拖动滑块(或自动演示) 4. 滑块到底 → 锁屏向上滑出 + 淡出 5. 主屏 App 图标依次缩放淡入 6. 视差:根据鼠标位置轻微移动壁纸 **Canvas 实现要点:** - 使用 `requestAnimationFrame` - 缓动函数:cubic-bezier 模拟,如 easeOutExpo, easeInOutCubic - 高 DPI 适配 - 状态机管理 **App 图标设计:** 原创设计,不抄袭。可以是: - 一个相机(镜头 + 光圈) - 一个邮件(信封) - 一个天气(太阳 + 云) - 一个地图(折叠地图) - 一个音乐(音符或波形) - 一个计算器(栅格) - 一个时钟(表盘) - 一个相册(图片堆叠) - 一个备忘录(横线) - 一个健康(心形) - 一个设置(齿轮) - 一个商店(购物袋) **实现细节:** 1. **手机外框**:深色金属感,圆角矩形(200x420 左右比例),刘海或动态岛 2. **壁纸渐变**:使用 radialGradient 或多个渐变叠加,制造柔和感 3. **时钟**:大号细字重数字(SF Pro 风格但用系统字体) 4. **滑动轨道**:圆角长条,半透明白色背景,内部文字带流光 5. **滑块**:圆形带箭头,阴影 6. **流光效果**:使用 createLinearGradient 移动 **交互:** - 鼠标按下 + 拖动滑块 - 滑到 90% 以上触发解锁 - 未到顶松开则回弹 - 也可点击自动演示 让我开始写代码。 ```javascript // 状态机 const STATE = { LOADING: 0, LOCKED: 1, // 显示锁屏,可拖动滑块 UNLOCKING: 2, // 锁屏淡出滑出 HOME: 3 // 显示主屏 }; ``` **关键算法:** ```javascript // 缓动 function easeOutExpo(t) { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); } function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } function easeOutBack(t) { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); } ``` **绘制 App 图标:** 我会创建一个图标库函数,每个图标接收 绘制。 让我开始详细设计每个 App 图标: 1. **照片**:彩色花瓣风格(仿iOS但自己绘制) 2. **相机**:黄色镜头 3. **信息**:绿色气泡 4. **电话**:绿色听筒 5. **邮件**:蓝色信封 6. **音乐**:红色 + 白色音符 7. **地图**:浅色 + 道路 8. **天气**:蓝色 + 太阳 9. **日历**:白色 + 红色标题 10. **时钟**:黑色表盘 11. **设置**:灰色齿轮 12. **App Store**:蓝色 + A 字 13. **健康**:白色 + 心形 14. **备忘录**:黄色横线 为了避免抄袭,我会用原创的几何抽象图标设计。让我做一组: 1. **画廊**:抽象色块 2. **相机**:圆形光圈 3. **通讯**:圆弧 4. **音乐**:波形 5. **天气**:太阳云 6. **地图**:网格 + 标记 7. **日程**:栅格 8. **时钟**:表盘 9. **健身**:心率 10. **笔记**:横线 11. **设置**:齿轮 12. **钱包**:卡片 每个图标圆角约 25%,背景渐变。 **代码结构:** ```html <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Lock Screen</title> <style> /* 全屏黑色背景,居中 canvas */ </style> </head> <body> <canvas id="c"></canvas> <script> // 所有代码 </script> </body> </html> ``` 让我开始写完整代码: ```javascript const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); let W, H, DPR; function resize() { DPR = Math.min(window.devicePixelRatio || 1, 2); W = window.innerWidth; H = window.innerHeight; canvas.width = W * DPR; canvas.height = H * DPR; canvas.style.width = W + 'px'; canvas.style.height = H + 'px'; ctx.setTransform(DPR, 0, 0, DPR, 0, 0); } resize(); window.addEventListener('resize', resize); // 手机尺寸 function getPhoneRect() { const aspectH = 800; // 高度基准 const aspectW = 380; // 宽度基准 const maxH = Math.min(H * 0.92, 820); const phoneH = maxH; const phoneW = phoneH * (aspectW / aspectH); return { x: (W - phoneW) / 2, y: (H - phoneH) / 2, w: phoneW, h: phoneH, radius: phoneW * 0.18 }; } // 状态 let state = 'LOCKED'; let progress = 0; // 滑块进度 0-1 let isDragging = false; let unlockAnim = 0; // 0-1 解锁动画进度 let homeAnim = 0; // 0-1 主屏进入动画 let shimmerPhase = 0; // 流光相位 let mouseX = 0, mouseY = 0; let parallaxX = 0, parallaxY = 0; // 时间 function getTime() { const d = new Date(); let h = d.getHours(); const m = d.getMinutes(); const ampm = h >= 12 ? 'PM' : 'AM'; // 实际 24h 制 return { h: String(h).padStart(2, '0'), m: String(m).padStart(2, '0'), date: d.getDate(), weekday: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()], month: ['January','February','March','April','May','June','July','August','September','October','November','December'][d.getMonth()] }; } ``` 接下来是绘制函数: ```javascript function drawPhoneFrame(phone) { // 外框阴影 ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.6)'; ctx.shadowBlur = 60; ctx.shadowOffsetY = 30; // 外框金属色 const grad = ctx.createLinearGradient(phone.x, phone.y, phone.x + phone.w, phone.y + phone.h); grad.addColorStop(0, '#3a3a3c'); grad.addColorStop(0.5, '#1c1c1e'); grad.addColorStop(1, '#0a0a0c'); ctx.fillStyle = grad; roundRect(ctx, phone.x - 8, phone.y - 8, phone.w + 16, phone.h + 16, phone.radius + 8); ctx.fill(); ctx.restore(); // 屏幕(裁剪区域) ctx.save(); roundRect(ctx, phone.x, phone.y, phone.w, phone.h, phone.radius); ctx.clip(); } function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } ``` **壁纸绘制:** ```javascript function drawWallpaper(phone, t) { // 暖色渐变 + 视差 const offX = parallaxX * 15; const offY = parallaxY * 15; const g = ctx.createLinearGradient( phone.x - offX, phone.y - offY, phone.x + phone.w + offX, phone.y + phone.h + offY ); g.addColorStop(0, '#ff9966'); g.addColorStop(0.5, '#ff5e62'); g.addColorStop(1, '#2c1810'); ctx.fillStyle = g; ctx.fillRect(phone.x - 30, phone.y - 30, phone.w + 60, phone.h + 60); // 加几个柔光圆 drawSoftCircle(phone.x + phone.w * 0.3 + offX * 1.5, phone.y + phone.h * 0.3 + offY * 1.5, 200, 'rgba(255,200,150,0.4)'); drawSoftCircle(phone.x + phone.w * 0.8 + offX * 1.5, phone.y + phone.h * 0.6 + offY * 1.5, 250, 'rgba(120,40,80,0.5)'); } ``` **时钟绘制:** ```javascript function drawLockScreen(phone, alpha, offsetY) { ctx.save(); ctx.globalAlpha = alpha; ctx.translate(0, offsetY); // 顶部状态栏 drawStatusBar(phone); // 大时钟 const cx = phone.x + phone.w / 2; const clockY = phone.y + phone.h * 0.35; const time = getTime(); ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `200 ${phone.w * 0.28}px -apple-system, system-ui, sans-serif`; // 时间字符串 ctx.fillText(`${time.h}`, cx - phone.w * 0.08, clockY); ctx.fillText(`${time.m}`, cx + phone.w * 0.08, clockY); ctx.fillText(':', cx, clockY - phone.w * 0.02); // 日期 ctx.font = `500 ${phone.w * 0.05}px -apple-system, system-ui, sans-serif`; ctx.fillStyle = 'rgba(255,255,255,0.9)'; ctx.fillText(`${time.weekday}, ${time.month} ${time.date}`, cx, clockY - phone.w * 0.20); // 滑动解锁 drawSlider(phone); // Home indicator drawHomeIndicator(phone); ctx.restore(); } ``` **滑动解锁 + 流光:** ```javascript function drawSlider(phone) { const trackY = phone.y + phone.h * 0.86; const trackW = phone.w * 0.82; const trackX = phone.x + (phone.w - trackW) / 2; const trackH = 56; const radius = trackH / 2; // 轨道背景 ctx.save(); roundRect(ctx, trackX, trackY, trackW, trackH, radius); ctx.fillStyle = 'rgba(255,255,255,0.15)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; ctx.stroke(); // 文字 + 流光 ctx.save(); roundRect(ctx, trackX, trackY, trackW, trackH, radius); ctx.clip(); ctx.font = `500 ${trackH * 0.32}px -apple-system, system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const textCx = trackX + trackW / 2; const textCy = trackY + trackH / 2; // 基础文字 ctx.fillStyle = 'rgba(255,255,255,0.85)'; ctx.fillText('Slide to Unlock', textCx, textCy); // 流光层 - 用渐变模拟 const shimmerW = trackW * 0.5; const shimmerX = trackX + (Math.sin(shimmerPhase) * 0.5 + 0.5) * trackW - shimmerW / 2; const sg = ctx.createLinearGradient(shimmerX, 0, shimmerX + shimmerW, 0); sg.addColorStop(0, 'rgba(255,255,255,0)'); sg.addCol
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Slide to Unlock</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
  width: 100%; height: 100%;
  overflow: hidden;
  background: radial-gradient(ellipse 70% 50% at 50% 50%, #1a1118 0%, #050405 75%);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, Arial, sans-serif;
  color: white;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
canvas { display: block; width: 100vw; height: 100vh; touch-action: none; }
.hint {
  position: fixed; bottom: 18px; left: 50%;
  transform: translateX(-50%);
  color: rgba(255,255,255,0.32);
  font-size: 10.5px; font-weight: 300;
  letter-spacing: 0.32em; text-transform: uppercase;
  pointer-events: none;
  transition: opacity 0.6s ease;
  white-space: nowrap;
}
.hint.hidden { opacity: 0; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div class="hint" id="hint">Drag the knob &nbsp;·&nbsp; Double-click to replay</div>
<script>
'use strict';

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const hintEl = document.getElementById('hint');

let W = 0, H = 0, DPR = 1;
function resize() {
  DPR = Math.min(window.devicePixelRatio || 1, 2);
  W = window.innerWidth; H = window.innerHeight;
  canvas.width = Math.floor(W * DPR);
  canvas.height = Math.floor(H * DPR);
  canvas.style.width = W + 'px';
  canvas.style.height = H + 'px';
  ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
resize();
window.addEventListener('resize', resize);

function rr(c, x, y, w, h, r) {
  if (w < 0) { x += w; w = -w; }
  if (h < 0) { y += h; h = -h; }
  r = Math.max(0, Math.min(r, Math.min(w, h) / 2));
  c.beginPath();
  c.moveTo(x + r, y);
  c.arcTo(x + w, y, x + w, y + h, r);
  c.arcTo(x + w, y + h, x, y + h, r);
  c.arcTo(x, y + h, x, y, r);
  c.arcTo(x, y, x + w, y, r);
  c.closePath();
}
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const ease = {
  outCubic: t => 1 - Math.pow(1 - t, 3),
  inOutCubic: t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2,
  outExpo: t => t >= 1 ? 1 : 1 - Math.pow(2, -10 * t),
  outBack: t => {
    const c1 = 1.70158, c3 = c1 + 1;
    return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
  }
};

// 状态机
const ST = { LOCKED: 0, UNLOCKING: 1, HOME: 2 };
let state = ST.LOCKED;
let sliderProgress = 0;
let isDragging = false;
let dragStartX = 0, dragStartProgress = 0;
let unlockT = 0, homeT = 0;
let lockFade = 1, lockOffsetY = 0, homeAlpha = 0;
let shimmerT = 0;
let parallaxX = 0, parallaxY = 0;
let parallaxTX = 0, parallaxTY = 0;
let entryT = 0;
let phone = { x: 0, y: 0, w: 0, h: 0, r: 0 };

function updatePhone() {
  const aw = 380, ah = 800;
  const mh = Math.min(H * 0.94, 880);
  const pw = mh * (aw / ah);
  phone = {
    x: Math.round((W - pw) / 2),
    y: Math.round((H - mh) / 2),
    w: pw, h: mh,
    r: Math.min(pw * 0.28, 56)
  };
}

function getTime() {
  const d = new Date();
  const wd = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()];
  const mo = ['January','February','March','April','May','June','July','August','September','October','November','December'][d.getMonth()];
  return {
    h: String(d.getHours()).padStart(2, '0'),
    m: String(d.getMinutes()).padStart(2, '0'),
    day: d.getDate(),
    weekday: wd, month: mo
  };
}

// ===== 图标绘制 =====
function drawGallery(c, s) {
  const cx = s/2, cy = s/2;
  const colors = ['#FFCD00','#FF9500','#FF2D55','#AF52DE','#0A84FF','#34C759','#FFCC00','#FF6482'];
  for (let i = 0; i < 8; i++) {
    const a = i * Math.PI / 4 - Math.PI/2;
    c.save();
    c.translate(cx + Math.cos(a) * s*0.15, cy + Math.sin(a) * s*0.15);
    c.rotate(a + Math.PI/2);
    c.fillStyle = colors[i];
    c.beginPath();
    c.ellipse(0, 0, s*0.10, s*0.16, 0, 0, Math.PI*2);
    c.fill();
    c.restore();
  }
}
function drawCamera(c, s) {
  c.fillStyle = '#1c1c1e';
  rr(c, s*0.36, s*0.22, s*0.28, s*0.07, s*0.02);
  c.fill();
  rr(c, s*0.14, s*0.28, s*0.72, s*0.50, s*0.08);
  c.fill();
  const g = c.createRadialGradient(s*0.42, s*0.46, 0, s*0.5, s*0.52, s*0.22);
  g.addColorStop(0, '#3a3a3c');
  g.addColorStop(0.6, '#1c1c1e');
  g.addColorStop(1, '#000');
  c.fillStyle = g;
  c.beginPath(); c.arc(s*0.5, s*0.53, s*0.18, 0, Math.PI*2); c.fill();
  c.fillStyle = 'rgba(140,200,255,0.45)';
  c.beginPath(); c.arc(s*0.44, s*0.47, s*0.055, 0, Math.PI*2); c.fill();
  c.fillStyle = '#FFE066';
  c.beginPath(); c.arc(s*0.76, s*0.38, s*0.038, 0, Math.PI*2); c.fill();
}
function drawChats(c, s) {
  c.fillStyle = '#fff';
  rr(c, s*0.18, s*0.22, s*0.64, s*0.42, s*0.13); c.fill();
  c.beginPath();
  c.moveTo(s*0.32, s*0.58);
  c.lineTo(s*0.26, s*0.76);
  c.lineTo(s*0.46, s*0.62);
  c.closePath(); c.fill();
  c.fillStyle = '#06A838';
  for (let i = 0; i < 3; i++) {
    c.beginPath(); c.arc(s*0.36 + i*s*0.14, s*0.43, s*0.042, 0, Math.PI*2); c.fill();
  }
}
function drawMail(c, s) {
  c.fillStyle = '#fff';
  rr(c, s*0.14, s*0.26, s*0.72, s*0.48, s*0.06); c.fill();
  c.fillStyle = '#0A78E8';
  c.beginPath();
  c.moveTo(s*0.14, s*0.30);
  c.lineTo(s*0.5, s*0.56);
  c.lineTo(s*0.86, s*0.30);
  c.lineTo(s*0.86, s*0.26);
  c.lineTo(s*0.14, s*0.26);
  c.closePath(); c.fill();
}
function drawMusic(c, s) {
  c.fillStyle = '#fff';
  rr(c, s*0.55, s*0.22, s*0.045, s*0.42, s*0.02); c.fill();
  rr(c, s*0.32, s*0.27, s*0.045, s*0.37, s*0.02); c.fill();
  c.beginPath();
  c.moveTo(s*0.32, s*0.27);
  c.lineTo(s*0.595, s*0.22);
  c.lineTo(s*0.595, s*0.30);
  c.lineTo(s*0.32, s*0.35);
  c.closePath(); c.fill();
  c.beginPath(); c.ellipse(s*0.29, s*0.64, s*0.10, s*0.08, -0.2, 0, Math.PI*2); c.fill();
  c.beginPath(); c.ellipse(s*0.52, s*0.64, s*0.10, s*0.08, -0.2, 0, Math.PI*2); c.fill();
}
function drawMaps(c, s) {
  c.fillStyle = '#E8F4E8'; c.fillRect(0, 0, s, s);
  c.strokeStyle = '#FFFFFF';
  c.lineWidth = s*0.07; c.lineCap = 'round';
  c.beginPath(); c.moveTo(s*0.08, s*0.35); c.lineTo(s*0.92, s*0.55); c.stroke();
  c.beginPath(); c.moveTo(s*0.7, s*0.05); c.lineTo(s*0.3, s*0.95); c.stroke();
  c.strokeStyle = '#7BC0E8'; c.lineWidth = s*0.05;
  c.beginPath();
  c.moveTo(s*0, s*0.85);
  c.bezierCurveTo(s*0.35, s*0.78, s*0.5, s*0.95, s, s*0.72);
  c.stroke();
  c.fillStyle = '#FF3B30';
  c.save(); c.translate(s*0.5, s*0.42);
  c.beginPath();
  c.moveTo(0, 0);
  c.bezierCurveTo(-s*0.10, -s*0.12, -s*0.06, -s*0.20, 0, -s*0.20);
  c.bezierCurveTo(s*0.06, -s*0.20, s*0.10, -s*0.12, 0, 0);
  c.fill();
  c.fillStyle = '#fff';
  c.beginPath(); c.arc(0, -s*0.12, s*0.035, 0, Math.PI*2); c.fill();
  c.restore();
}
function drawWeather(c, s) {
  const g = c.createRadialGradient(s*0.6, s*0.38, 0, s*0.6, s*0.38, s*0.22);
  g.addColorStop(0, '#FFE066'); g.addColorStop(1, '#FFA533');
  c.fillStyle = g;
  c.beginPath(); c.arc(s*0.6, s*0.4, s*0.17, 0, Math.PI*2); c.fill();
  c.fillStyle = '#fff';
  c.beginPath();
  c.arc(s*0.38, s*0.62, s*0.14, 0, Math.PI*2);
  c.arc(s*0.54, s*0.58, s*0.18, 0, Math.PI*2);
  c.arc(s*0.7, s*0.64, s*0.13, 0, Math.PI*2);
  c.arc(s*0.52, s*0.72, s*0.12, 0, Math.PI*2);
  c.fill();
}
function drawCalendar(c, s) {
  c.fillStyle = '#FF3B30';
  rr(c, s*0.14, s*0.20, s*0.72, s*0.18, s*0.04); c.fill();
  c.fillStyle = '#1c1c1e';
  c.font = `700 ${Math.round(s*0.42)}px -apple-system, sans-serif`;
  c.textAlign = 'center'; c.textBaseline = 'middle';
  c.fillText(new Date().getDate(), s*0.5, s*0.62);
  c.fillStyle = '#fff';
  c.font = `700 ${Math.round(s*0.11)}px -apple-system, sans-serif`;
  const wd = ['SUN','MON','TUE','WED','THU','FRI','SAT'][new Date().getDay()];
  c.fillText(wd, s*0.5, s*0.29);
}
function drawClock(c, s) {
  c.fillStyle = '#fff';
  c.beginPath(); c.arc(s*0.5, s*0.5, s*0.32, 0, Math.PI*2); c.fill();
  c.strokeStyle = '#000'; c.lineWidth = s*0.012;
  for (let i = 0; i < 12; i++) {
    const a = i * Math.PI / 6;
    const r1 = i % 3 === 0 ? s*0.25 : s*0.27;
    c.beginPath();
    c.moveTo(s*0.5 + Math.cos(a)*r1, s*0.5 + Math.sin(a)*r1);
    c.lineTo(s*0.5 + Math.cos(a)*s*0.30, s*0.5 + Math.sin(a)*s*0.30);
    c.stroke();
  }
  const t = new Date();
  const a1 = ((t.getHours() % 12) + t.getMinutes()/60) * Math.PI/6 - Math.PI/2;
  const a2 = (t.getMinutes() + t.getSeconds()/60) * Math.PI/30 - Math.PI/2;
  const a3 = (t.getSeconds() + t.getMilliseconds()/1000) * Math.PI/30 - Math.PI/2;
  c.strokeStyle = '#000';
  c.lineWidth = s*0.035; c.lineCap = 'round';
  c.beginPath(); c.moveTo(s*0.5, s*0.5);
  c.lineTo(s*0.5 + Math.cos(a1)*s*0.14, s*0.5 + Math.sin(a1)*s*0.14); c.stroke();
  c.lineWidth = s*0.025;
  c.beginPath(); c.moveTo(s*0.5, s*0.5);
  c.lineTo(s*0.5 + Math.cos(a2)*s*0.22, s*0.5 + Math.sin(a2)*s*0.22); c.stroke();
  c.strokeStyle = '#FF9500'; c.lineWidth = s*0.012;
  c.beginPath();
  c.moveTo(s*0.5 - Math.cos(a3)*s*0.05, s*0.5 - Math.sin(a3)*s*0.05);
  c.lineTo(s*0.5 + Math.cos(a3)*s*0.24, s*0.5 + Math.sin(a3)*s*0.24);
  c.stroke();
  c.fillStyle = '#FF9500';
  c.beginPath(); c.arc(s*0.5, s*0.5, s*0.025, 0, Math.PI*2); c.fill();
}
function drawHealth(c, s) {
  c.fillStyle = '#fff';
  const cx = s*0.5, cy = s*0.5;
  c.beginPath();
  c.moveTo(cx, cy + s*0.25);
  c.bezierCurveTo(cx - s*0.32, cy - s*0.02, cx - s*0.20, cy - s*0.30, cx, cy - s*0.08);
  c.bezierCurveTo(cx + s*0.20, cy - s*0.30, cx + s*0.32, cy - s*0.02, cx, cy + s*0.25);
  c.fill();
  c.strokeStyle = '#FF5E62';
  c.lineWidth = s*0.028; c.lineCap = 'round'; c.lineJoin = 'round';
  c.beginPath();
  c.moveTo(s*0.32, s*0.5);
  c.lineTo(s*0.40, s*0.5);
  c.lineTo(s*0.45, s*0.40);
  c.lineTo(s*0.52, s*0.60);
  c.lineTo(s*0.58, s*0.5);
  c.lineTo(s*0.68, s*0.5);
  c.stroke();
}
function drawWallet(c, s) {
  const cards = [
    { y: 0.26, c: '#FF9500' },
    { y: 0.36, c: '#34C759' },
    { y: 0.46, c: '#FF2D55' }
  ];
  for (let i = 0; i < 3; i++) {
    c.fillStyle = cards[i].c;
    rr(c, s*0.18, s*cards[i].y, s*0.64, s*0.34, s*0.04); c.fill();
  }
  c.fillStyle = '#FFE066';
  rr(c, s*0.18, s*0.32, s*0.64, s*0.045, s*0.01); c.fill();
}
function drawNotes(c, s) {
  c.fillStyle = '#FFB400';
  rr(c, s*0.16, s*0.22, s*0.68, s*0.08, s*0.02); c.fill();
  c.strokeStyle = '#B57800'; c.lineWidth = s*0.02;
  for (let i = 0; i < 5; i++) {
    c.beginPath();
    c.moveTo(s*0.2, s*0.42 + i*s*0.08);
    c.lineTo(s*0.8, s*0.42 + i*s*0.08);
    c.stroke();
  }
}
function drawTasks(c, s) {
  c.strokeStyle = '#FF6B35'; c.lineWidth = s*0.022; c.lineCap = 'round';
  for (let i = 0; i < 3; i++) {
    const y = s*0.32 + i*s*0.2;
    c.beginPath(); c.arc(s*0.3, y, s*0.06, 0, Math.PI*2); c.stroke();
    c.beginPath(); c.moveTo(s*0.43, y); c.lineTo(s*0.78, y); c.stroke();
  }
  c.strokeStyle = '#FF6B35'; c.lineWidth = s*0.028;
  c.beginPath();
  c.moveTo(s*0.26, s*0.32);
  c.lineTo(s*0.30, s*0.36);
  c.lineTo(s*0.36, s*0.28);
  c.stroke();
}
function drawSettings(c, s) {
  c.fillStyle = '#fff';
  const cx = s*0.5, cy = s*0.5;
  const teeth = 8;
  c.beginPath();
  for (let i = 0; i < teeth * 2; i++) {
    const a = i * Math.PI / teeth - Math.PI/2;
    const r = (i % 2 === 0) ? s*0.32 : s*0.25;
    const x = cx + Math.cos(a) * r;
    const y = cy + Math.sin(a) * r;
    if (i === 0) c.moveTo(x, y); else c.lineTo(x, y);
  }
  c.closePath(); c.fill();
  c.fillStyle = '#484C50';
  c.beginPath(); c.arc(cx, cy, s*0.09, 0, Math.PI*2); c.fill();
}
function drawFiles(c, s) {
  c.fillStyle = '#5AC8FA';
  rr(c, s*0.14, s*0.30, s*0.72, s*0.50, s*0.06); c.fill();
  c.fillStyle = '#34A2E8';
  rr(c, s*0.18, s*0.24, s*0.32, s*0.10, s*0.04); c.fill();
  c.fillStyle = 'rgba(255,255,255,0.4)';
  for (let i = 0; i < 3; i++) {
    rr(c, s*0.22, s*0.42 + i*s*0.10, s*0.56, s*0.04, s*0.01); c.fill();
  }
}
function drawStore(c, s) {
  c.fillStyle = '#fff';
  c.beginPath();
  c.arc(s*0.5, s*0.72, s*0.04, 0, Math.PI*2);
  c.arc(s*0.4, s*0.58, s*0.04, 0, Math.PI*2);
  c.arc(s*0.6, s*0.58, s*0.04, 0, Math.PI*2);
  c.fill();
  c.strokeStyle = '#fff';
  c.lineWidth = s*0.05; c.lineCap = 'round'; c.lineJoin = 'round';
  c.beginPath();
  c.moveTo(s*0.5, s*0.28);
  c.lineTo(s*0.35, s*0.65);
  c.lineTo(s*0.65, s*0.65);
  c.closePath();
  c.stroke();
}
function drawPhone(c, s) {
  c.save();
  c.translate(s*0.5, s*0.5);
  c.rotate(-0.35);
  c.fillStyle = '#fff';
  rr(c, -s*0.15, -s*0.22, s*0.30, s*0.44, s*0.15); c.fill();
  c.fillStyle = '#06A838';
  for (let i = 0; i < 5; i++) {
    rr(c, -s*0.10, -s*0.18 + i*s*0.075, s*0.20, s*0.018, s*0.005); c.fill();
  }
  c.restore();
}
function drawBrowser(c, s) {
  c.fillStyle = '#fff';
  c.beginPath(); c.arc(s*0.5, s*0.5, s*0.32, 0, Math.PI*2); c.fill();
  c.strokeStyle = '#999'; c.lineWidth = s*0.01;
  for (let i = 0; i < 12; i++) {
    const a = i * Math.PI / 6;
    c.beginPath();
    c.moveTo(s*0.5 + Math.cos(a)*s*0.27, s*0.5 + Math.sin(a)*s*0.27);
    c.lineTo(s*0.5 + Math.cos(a)*s*0.31, s*0.5 + Math.sin(a)*s*0.31);
    c.stroke();
  }
  c.fillStyle = '#FF3B30';
  c.beginPath();
  c.moveTo(s*0.5, s*0.24);
  c.lineTo(s*0.57, s*0.5);
  c.lineTo(s*0.5, s*0.5);
  c.closePath(); c.fill();
  c.fillStyle = '#999';
  c.beginPath();
  c.moveTo(s*0.5, s*0.76);
  c.lineTo(s*0.43, s*0.5);
  c.lineTo(s*0.5, s*0.5);
  c.closePath(); c.fill();
  c.fillStyle = '#FF3B30';
  c.beginPath(); c.arc(s*0.5, s*0.5, s*0.03, 0, Math.PI*2); c.fill();
}

const ICONS = [
  { name: 'Gallery',  bg: ['#FFFFFF', '#DCDCDE'], draw: drawGallery },
  { name: 'Camera',   bg: ['#48484A', '#1C1C1E'], draw: drawCamera },
  { name: 'Chats',    bg: ['#5BE584', '#06A838'], draw: drawChats },
  { name: 'Mail',     bg: ['#3EC5FF', '#0A78E8'], draw: drawMail },
  { name: 'Music',    bg: ['#FB6FA8', '#E60053'], draw: drawMusic },
  { name: 'Maps',     bg: ['#E8F5E0', '#C5E3C8'], draw: drawMaps },
  { name: 'Weather',  bg: ['#6BB6FF', '#1C5DD0'], draw: drawWeather },
  { name: 'Calendar', bg: ['#FFFFFF', '#EEEEEE'], draw: drawCalendar },
  { name: 'Clock',    bg: ['#1C1C1E', '#000000'], draw: drawClock },
  { name: 'Health',   bg: ['#FF9F7E', '#FF5E62'], draw: drawHealth },
  { name: 'Wallet',   bg: ['#3A3A3C', '#0A0A0C'], draw: drawWallet },
  { name: 'Notes',    bg: ['#FFE066', '#FFB400'], draw: drawNotes },
  { name: 'Tasks',    bg: ['#FFEEDD', '#FFCC99'], draw: drawTasks },
  { name: 'Settings', bg: ['#B0B6BE', '#484C50'], draw: drawSettings },
  { name: 'Files',    bg: ['#7BD3FF', '#34A2E8'], draw: drawFiles },
  { name: 'Store',    bg: ['#3EC5FF', '#0A78E8'], draw: drawStore }
];
const DOCK = [
  { name: 'Phone',   bg: ['#5BE584', '#06A838'], draw: drawPhone },
  { name: 'Browser', bg: ['#1C9CFF', '#0A5BE0'], draw: drawBrowser },
  { name: 'Mail',    bg: ['#3EC5FF', '#0A78E8'], draw: drawMail },
  { name: 'Music',   bg: ['#FB6FA8', '#E60053'], draw: drawMusic }
];

// ===== 绘制 =====
function drawWallpaper(p) {
  const px = -parallaxX * 16;
  const py = -parallaxY * 16;
  const g = ctx.createLinearGradient(p.x - 30 + px, p.y - 30 + py, p.x + p.w + 30 + px, p.y + p.h + 30 + py);
  g.addColorStop(0, '#FF8A3D');
  g.addColorStop(0.35, '#FF4D6D');
  g.addColorStop(0.7, '#A82345');
  g.addColorStop(1, '#2C0E1C');
  ctx.fillStyle = g;
  ctx.fillRect(p.x - 30, p.y - 30, p.w + 60, p.h + 60);
  function blob(x, y, r, c1) {
    const sg = ctx.createRadialGradient(x, y, 0, x, y, r);
    sg.addColorStop(0, c1);
    sg.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = sg;
    ctx.fillRect(p.x - 30, p.y - 30, p.w + 60, p.h + 60);
  }
  blob(p.x + p.w*0.3 + px*1.4, p.y + p.h*0.25 + py*1.4, p.w*0.65, 'rgba(255,200,140,0.5)');
  blob(p.x + p.w*0.78 - px*1.4, p.y + p.h*0.5 - py*1.4, p.w*0.7, 'rgba(255,80,140,0.45)');
  blob(p.x + p.w*0.5 + px*1.4, p.y + p.h*0.88 + py*1.4, p.w*0.7, 'rgba(40,10,30,0.6)');
  const vg = ctx.createRadialGradient(p.x + p.w/2, p.y + p.h/2, p.w*0.3, p.x + p.w/2, p.y + p.h/2, p.h*0.75);
  vg.addColorStop(0, 'rgba(0,0,0,0)');
  vg.addColorStop(1, 'rgba(0,0,0,0.5)');
  ctx.fillStyle = vg;
  ctx.fillRect(p.x, p.y, p.w, p.h);
}

function drawStatusBar(p) {
  const cx = p.x + p.w / 2;
  ctx.fillStyle = '#000';
  rr(ctx, cx - p.w*0.12, p.y + 12, p.w*0.24, 22, 12);
  ctx.fill();
  ctx.fillStyle = '#fff';
  ctx.font = `600 ${Math.round(p.w * 0.042)}px -apple-system, sans-serif`;
  ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
  const t = getTime();
  ctx.fillText(`${t.h}:${t.m}`, p.x + 18, p.y + 23);
  const rx = p.x + p.w - 18;
  ctx.fillStyle = '#fff';
  for (let i = 0; i < 4; i++) {
    rr(ctx, rx - 30 + i*4, p.y + 22 - i*2, 3, 4 + i*2, 1); ctx.fill();
  }
  ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.6;
  ctx.beginPath();
  ctx.arc(rx - 14, p.y + 22, 5, Math.PI*1.2, Math.PI*1.8, true);
  ctx.stroke();
  ctx.fillStyle = '#fff';
  ctx.beginPath(); ctx.arc(rx - 14, p.y + 22, 1.8, 0, Math.PI*2); ctx.fill();
  rr(ctx, rx - 22, p.y + 17, 22, 11, 2.5);
  ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fill();
  rr(ctx, rx - 20, p.y + 19, 16, 7, 1.5);
  ctx.fillStyle = '#fff'; ctx.fill();
  rr(ctx, rx - 1, p.y + 20, 1.6, 5, 0.5);
  ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.fill();
}

function drawHomeIndicator(p) {
  ctx.fillStyle = state === ST.HOME ? 'rgba(255,255,255,0.55)' : 'rgba(255,255,255,0.85)';
  rr(ctx, p.x + p.w/2 - 50, p.y + p.h - 8, 100, 4, 2);
  ctx.fill();
}

function drawLockIcon(cx, cy) {
  ctx.save();
  ctx.strokeStyle = 'rgba(255,255,255,0.85)';
  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  ctx.lineWidth = 2; ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.arc(cx, cy - 2, 6, Math.PI, 0, false);
  ctx.stroke();
  rr(ctx, cx - 7, cy - 2, 14, 12, 2);
  ctx.fill();
  ctx.restore();
}

function drawLockScreen(p, alpha, offsetY) {
  ctx.save();
  ctx.globalAlpha = alpha;
  const cx = p.x + p.w / 2;
  drawLockIcon(cx, p.y + p.h * 0.16 + offsetY);
  const t = getTime();
  ctx.fillStyle = 'rgba(255,255,255,0.92)';
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.font = `600 ${Math.round(p.w * 0.058)}px -apple-system, system-ui, sans-serif`;
  ctx.fillText(`${t.weekday}, ${t.month} ${t.day}`, cx, p.y + p.h * 0.26 + offsetY);
  // 大时钟(视差微动)
  const lx = -parallaxX * 3;
  const ly = -parallaxY * 3;
  ctx.fillStyle = '#fff';
  ctx.font = `200 ${Math.round(p.w * 0.36)}px -apple-system, system-ui, sans-serif`;
  ctx.textAlign = 'center';
  ctx.fillText(t.h, cx - p.w * 0.095 + lx, p.y + p.h * 0.44 + offsetY + ly);
  ctx.fillText(t.m, cx + p.w * 0.095 + lx, p.y + p.h * 0.44 + offsetY + ly);
  ctx.fillText(':', cx + lx, p.y + p.h * 0.42 + offsetY + ly);
  drawSlider(p, offsetY);
  ctx.restore();
}

function drawSlider(p, offsetY) {
  const trackW = p.w * 0.84;
  const trackH = p.w * 0.18;
  const trackX = p.x + (p.w - trackW) / 2;
  const trackY = p.y + p.h * 0.84 + offsetY;
  const radius = trackH / 2;
  ctx.save();
  // 轨道
  rr(ctx, trackX, trackY, trackW, trackH, radius);
  ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fill();
  rr(ctx, trackX, trackY, trackW, trackH, radius);
  const tg = ctx.createLinearGradient(0, trackY, 0, trackY + trackH);
  tg.addColorStop(0, 'rgba(255,255,255,0.20)');
  tg.addColorStop(0.5, 'rgba(255,255,255,0)');
  tg.addColorStop(1, 'rgba(0,0,0,0.15)');
  ctx.fillStyle = tg; ctx.fill();
  // 边线
  rr(ctx, trackX + 0.5, trackY + 0.5, trackW - 1, trackH - 1, radius - 0.5);
  ctx.strokeStyle = 'rgba(255,255,255,0.18)';
  ctx.lineWidth = 1; ctx.stroke();

  // 文字 + 流光
  ctx.save();
  rr(ctx, trackX, trackY, trackW, trackH, radius);
  ctx.clip();
  const fontSize = Math.round(trackH * 0.34);
  ctx.font = `500 ${fontSize}px -apple-system, system-ui, sans-serif`;
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  const textCx = trackX + trackW / 2;
  const textCy = trackY + trackH / 2;
  // 基础暗文字
  ctx.fillStyle = 'rgba(255,255,255,0.55)';
  ctx.fillText('Slide to Unlock', textCx, textCy);
  // 流光(再次绘制文字,用移动的渐变填充)
  const period = 2.4;
  const phase = (shimmerT % period) / period;
  const shimmerCenter = trackX + (phase * 1.4 - 0.2) * trackW;
  const shimmerW = trackW * 0.55;
  const sg = ctx.createLinearGradient(shimmerCenter - shimmerW/2, 0, shimmerCenter + shimmerW/2, 0);
  sg.addColorStop(0, 'rgba(255,255,255,0)');
  sg.addColorStop(0.5, 'rgba(255,255,255,0.95)');
  sg.addColorStop(1, 'rgba(255,255,255,0)');
  ctx.fillStyle = sg;
  ctx.fillText('Slide to Unlock', textCx, textCy);
  ctx.restore();

  // 滑块
  const knobX = trackX + radius + sliderProgress * (trackW - radius * 2);
  const knobR = radius - 5;
  ctx.save();
  ctx.shadowColor = 'rgba(0,0,0,0.4)';
  ctx.shadowBlur = 14;
  ctx.shadowOffsetY = 5;
  const kg = ctx.createLinearGradient(0, trackY, 0, trackY + trackH);
  kg.addColorStop(0, '#ffffff');
  kg.addColorStop(1, '#d8d8da');
  ctx.fillStyle = kg;
  ctx.beginPath();
  ctx.arc(knobX, trackY + trackH / 2, knobR, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
  // 滑块高光
  ctx.save();
  ctx.beginPath();
  ctx.arc(knobX, trackY + trackH / 2, knobR, 0, Math.PI * 2);
  ctx.clip();
  const hlG = ctx.createLinearGradient(0, trackY, 0, trackY + trackH * 0.55);
  hlG.addColorStop(0, 'rgba(255,255,255,0.85)');
  hlG.addColorStop(1, 'rgba(255,255,255,0)');
  ctx.fillStyle = hlG;
  ctx.fillRect(knobX - knobR, trackY, knobR * 2, trackH * 0.55);
  ctx.restore();
  // 箭头
  ctx.fillStyle = '#FF4D6D';
  ctx.font = `700 ${Math.round(trackH * 0.42)}px -apple-system, sans-serif`;
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
  ctx.fillText('›', knobX, trackY + trackH / 2 - 2);
  ctx.restore();
}

function drawAppIcon(icon, x, y, size, sc, alpha) {
  ctx.save();
  ctx.globalAlpha = alpha;
  const cx = x + size/2, cy = y + size/2;
  ctx.translate(cx, cy);
  ctx.scale(sc, sc);
  ctx.translate(-cx, -cy);
  // 阴影
  ctx.save();
  ctx.shadowColor = 'rgba(0,0,0,0.3)';
  ctx.shadowBlur = 10;
  ctx.shadowOffsetY = 4;
  ctx.fillStyle = '#000';
  rr(ctx, x, y, size, size, size * 0.23); ctx.fill();
  ctx.restore();
  // 背景
  const bgGrad = ctx.createLinearGradient(x, y, x + size, y + size);
  bgGrad.addColorStop(0, icon.bg[0]);
  bgGrad.addColorStop(1, icon.bg[1]);
  ctx.fillStyle = bgGrad;
  rr(ctx, x, y, size, size, size * 0.23); ctx.fill();
  // 高光
  const hlG = ctx.createLinearGradient(x, y, x, y + size * 0.5);
  hlG.addColorStop(0, 'rgba(255,255,255,0.22)');
  hlG.addColorStop(1, 'rgba(255,255,255,0)');
  ctx.fillStyle = hlG;
  rr(ctx, x, y, size, size * 0.5, size * 0.23); ctx.fill();
  // 内容
  ctx.save();
  ctx.beginPath();
  rr(ctx, x, y, size, size, size * 0.23);
  ctx.clip();
  ctx.translate(x, y);
  icon.draw(ctx, size);
  ctx.restore();
  ctx.restore();
}

function drawHomeScreen(p, alpha) {
  ctx.save();
  ctx.globalAlpha = alpha;
  // 整体微缩放
  const scale = lerp(1.08, 1, ease.outCubic(unlockT));
  const cx = p.x + p.w / 2, cy = p.y + p.h / 2;
  ctx.translate(cx, cy);
  ctx.scale(scale, scale);
  ctx.translate(-cx, -cy);
  // 视差
  const px = parallaxX * 4;
  const py = parallaxY * 4;
  const cols = 4;
  const iconSize = p.w * 0.16;
  const gap = p.w * 0.05;
  const labelH = iconSize * 0.22;
  const cellH = iconSize + labelH + gap * 0.7;
  const totalW = cols * iconSize + (cols - 1) * gap;
  const startX = p.x + (p.w - totalW) / 2;
  const startY = p.y + p.h * 0.14;
  for (let i = 0; i < ICONS.length; i++) {
    const r = Math.floor(i / cols);
    const c = i % cols;
    const ix = startX + c * (iconSize + gap) + px;
    const iy = startY + r * cellH + py;
    const delay = r * 0.05 + c * 0.03;
    const localT = clamp((homeT - delay) / 0.55, 0, 1);
    if (localT <= 0) continue;
    const ea = ease.outBack(localT);
    const sc = lerp(0.3, 1, ea);
    const op = ease.outCubic(localT);
    drawAppIcon(ICONS[i], ix, iy, iconSize, sc, op);
    if (op > 0.3) {
      ctx.save();
      ctx.globalAlpha = (op - 0.3) / 0.7 * alpha;
      ctx.fillStyle = '#fff';
      ctx.font = `400 ${Math.round(iconSize * 0.20)}px -apple-system, system-ui, sans-serif`;
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.shadowColor = 'rgba(0,0,0,0.5)';
      ctx.shadowBlur = 4; ctx.shadowOffsetY = 1;
      ctx.fillText(ICONS[i].name, ix + iconSize/2, iy + iconSize + labelH * 0.45);
      ctx.restore();
    }
  }
  // Dock
  const dockIconSize = iconSize;
  const dockGap = gap;
  const dockTotalW = cols * dockIconSize + (cols - 1) * dockGap;
  const dockStartX = p.x + (p.w - dockTotalW) / 2;
  const dockY = p.y + p.h * 0.86;
  // Dock 背景
  ctx.save();
  ctx.globalAlpha = alpha * 0.9;
  rr(ctx, dockStartX - gap/2 - 6, dockY - gap/2, dockTotalW + gap + 12, dockIconSize + gap, p.r * 0.32);
  ctx.fillStyle = 'rgba(255,255,255,0.16)'; ctx.fill();
  const dg = ctx.createLinearGradient(0, dockY - gap/2, 0, dockY - gap/2 + (dockIconSize + gap));
  dg.addColorStop(0, 'rgba(255,255,255,0.15)');
  dg.addColorStop(0.3, 'rgba(255,255,255,0)');
  dg.addColorStop(1, 'rgba(0,0,0,0.18)');
  ctx.fillStyle = dg;
  rr(ctx, dockStartX - gap/2 - 6, dockY - gap/2, dockTotalW + gap + 12, dockIconSize + gap, p.r * 0.32);
  ctx.fill();
  ctx.restore();
  for (let i = 0; i < DOCK.length; i++) {
    const ix = dockStartX + i * (dockIconSize + dockGap) + px;
    const iy = dockY + py;
    const delay = 0.4 + i * 0.03;
    const localT = clamp((homeT - delay) / 0.5, 0, 1);
    if (localT <= 0) continue;
    const ea = ease.outBack(localT);
    const sc = lerp(0.3, 1, ea);
    const op = ease.outCubic(localT);
    drawAppIcon(DOCK[i], ix, iy, dockIconSize, sc, op);
  }
  ctx.restore();
}

// ===== 更新 & 渲染 =====
function update(dt) {
  updatePhone();
  shimmerT += dt;
  parallaxX += (parallaxTX - parallaxX) * 0.06;
  parallaxY += (parallaxTY - parallaxY) * 0.06;
  if (entryT < 1) entryT = Math.min(1, entryT + dt / 1.0);
  if (state === ST.LOCKED) {
    if (!isDragging && sliderProgress > 0 && sliderProgress < 0.85) {
      sliderProgress = lerp(sliderProgress, 0, 0.18);
      if (sliderProgress < 0.001) sliderProgress = 0;
    }
    if (sliderProgress >= 0.93) {
      state = ST.UNLOCKING;
      unlockT = 0;
      hintEl.classList.add('hidden');
    }
    lockFade = 1; lockOffsetY = 0; homeAlpha = 0;
  } else if (state === ST.UNLOCKING) {
    unlockT = Math.min(1, unlockT + dt / 0.9);
    sliderProgress = lerp(sliderProgress, 1, 0.28);
    if (sliderProgress > 0.995) sliderProgress = 1;
    homeT = clamp((unlockT - 0.3) / 0.7, 0, 1);
    const e = ease.inOutCubic(unlockT);
    lockFade = 1 - e;
    lockOffsetY = -phone.h * 0.12 * e;
    homeAlpha = clamp((unlockT - 0.1) / 0.5, 0, 1);
    if (unlockT >= 1) {
      state = ST.HOME;
      homeT = 1;
    }
  } else if (state === ST.HOME) {
    homeT = 1; lockFade = 0; homeAlpha = 1;
  }
}

function render() {
  ctx.clearRect(0, 0, W, H);
  // 中心暖光
  const bgG = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, Math.max(W, H) * 0.5);
  bgG.addColorStop(0, 'rgba(60,20,30,0.55)');
  bgG.addColorStop(1, 'rgba(0,0,0,0)');
  ctx.fillStyle = bgG;
  ctx.fillRect(0, 0, W, H);
  // 入场
  const entryE = ease.outExpo(entryT);
  const entryOffset = (1 - entryE) * 60;
  const entryAlpha = entryE;
  ctx.save();
  ctx.globalAlpha = entryAlpha;
  ctx.translate(0, entryOffset);
  // 外框阴影
  ctx.save();
  ctx.shadowColor = 'rgba(0,0,0,0.6)';
  ctx.shadowBlur = 70;
  ctx.shadowOffsetY = 35;
  ctx.fillStyle = '#000';
  rr(ctx, phone.x - 14, phone.y - 14, phone.w + 28, phone.h + 28, phone.r + 14);
  ctx.fill();
  ctx.restore();
  // 金属边
  const mg = ctx.createLinearGradient(phone.x, phone.y, phone.x + phone.w, phone.y + phone.h);
  mg.addColorStop(0, '#5C5C5E');
  mg.addColorStop(0.3, '#3A3A3C');
  mg.addColorStop(0.5, '#2C2C2E');
  mg.addColorStop(0.7, '#1C1C1E');
  mg.addColorStop(1, '#48484A');
  ctx.fillStyle = mg;
  rr(ctx, phone.x - 6, phone.y - 6, phone.w + 12, phone.h + 12, phone.r + 6);
  ctx.fill();
  // 屏幕底
  ctx.fillStyle = '#000';
  rr(ctx, phone.x, phone.y, phone.w, phone.h, phone.r);
  ctx.fill();
  // 屏幕裁剪
  ctx.save();
  rr(ctx, phone.x, phone.y, phone.w, phone.h, phone.r);
  ctx.clip();
  drawWallpaper(phone);
  if (homeAlpha > 0.001) drawHomeScreen(phone, homeAlpha);
  if (lockFade > 0.001) drawLockScreen(phone, lockFade, lockOffsetY);
  drawStatusBar(phone);
  drawHomeIndicator(phone);
  // 屏幕反光
  const gloss = ctx.createLinearGradient(phone.x, phone.y, phone.x, phone.y + phone.h);
  gloss.addColorStop(0, 'rgba(255,255,255,0.06)');
  gloss.addColorStop(0.3, 'rgba(255,255,255,0)');
  ctx.fillStyle = gloss;
  ctx.fillRect(phone.x, phone.y, phone.w, phone.h);
  ctx.restore();
  ctx.restore();
}

// ===== 事件 =====
function getSliderRect() {
  return {
    trackX: phone.x + phone.w * 0.08,
    trackY: phone.y + phone.h * 0.84,
    trackW: phone.w * 0.84,
    trackH: phone.w * 0.18,
    radius: phone.w * 0.09
  };
}
function hitTestSlider(x, y) {
  const r = getSliderRect();
  const knobX = r.trackX + r.radius + sliderProgress * (r.trackW - r.radius * 2);
  const knobY = r.trackY + r.trackH / 2;
  if (Math.hypot(x - knobX, y - knobY) < r.radius + 10) return true;
  return x >= r.trackX && x <= r.trackX + r.trackW && y >= r.trackY && y <= r.trackY + r.trackH;
}
function onPointerDown(x, y) {
  parallaxTX = ((x / W) - 0.5) * 2;
  parallaxTY = ((y / H) - 0.5) * 2;
  if (state === ST.LOCKED && hitTestSlider(x, y)) {
    isDragging = true;
    dragStartX = x;
    dragStartProgress = sliderProgress;
  }
}
function onPointerMove(x, y) {
  parallaxTX = ((x / W) - 0.5) * 2;
  parallaxTY = ((y / H) - 0.5) * 2;
  if (isDragging) {
    const r = getSliderRect();
    const dx = x - dragStartX;
    sliderProgress = clamp(dragStartProgress + dx / (r.trackW - r.radius * 2), 0, 1);
  }
}
function onPointerUp() { isDragging = false; }
function onDoubleClick() {
  if (state === ST.HOME) {
    state = ST.LOCKED;
    sliderProgress = 0;
    unlockT = 0; homeT = 0;
    entryT = 0.6;
    hintEl.classList.remove('hidden');
  }
}
canvas.addEventListener('mousedown', e => onPointerDown(e.clientX, e.clientY));
canvas.addEventListener('mousemove', e => onPointerMove(e.clientX, e.clientY));
window.addEventListener('mouseup', () => onPointerUp());
canvas.addEventListener('dblclick', onDoubleClick);
canvas.addEventListener('touchstart', e => {
  if (e.touches.length) onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
  e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchmove', e => {
  if (e.touches.length) onPointerMove(e.touches[0].clientX, e.touches[0].clientY);
  e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchend', e => { onPointerUp(); e.preventDefault(); }, { passive: false });

// 主循环
let lastT = performance.now();
function loop(t) {
  const dt = Math.min((t - lastT) / 1000, 1/30);
  lastT = t;
  update(dt);
  render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
19.2s
TTFT
60
Reasoning TPS
812s · 48,761 tok
83
Output TPS
150s · 12,407 tok
61,168
Total Tokens (official)
Input 165
Elapsed 984s · Average 64 tok/s · Peak 217 tok/s · Cost $0.269