להקת קוי שוחה בשמי לילה, מגיבה לכוכבים שהמבקר מפזר, ומחזירה כל כוכב שנאכל אל הרקיע. הלהקה עצמה פרוצדורלית לחלוטין — Canvas 2D, מתמטיקה של להקות, ושלוש שכבות של אור — וסביבה נבנה אתר שלם: פאנלים צפים מעל השמיים, פרלקסה בגלילה, וגלריית חלומות שנוצרה ב-AI.
נקודת המוצא: מה אם בְּרֵכַת קוי יפנית הייתה שמי לילה? הדגים שוחים בין המזלות, והאינטראקציה היחידה היא האכלה — לחיצה מפזרת כוכב־מאכל, הלהקה נאספת, ומי שאוכל "מחזיר" כוכב חדש לשמיים. יש לולאת משוב רגשית שלמה: נתינה ← תגובה ← לידה. המונה בתחתית סופר כמה כוכבים נולדו.
בגרסה הראשונה זה היה מסך אחד בלבד. בגרסה הנוכחית העולם התרחב לאתר שלם — אבל העיקרון נשמר: השמיים הם הדף. שלושת הקנבסים קבועים (fixed) מאחורי הכול, והתוכן צף מעליהם בפאנלים שקופים־למחצה. הדגים ממשיכים לשחות מאחורי הטקסט, וכל רווח בין הפאנלים הוא עדיין שמיים חיים — אפשר להאכיל גם שם.
כל דג הוא "בואיד" (Craig Reynolds, 1986) שמציית לשלושה כוחות מקומיים בלבד — ומהם צומחת התנהגות להקתית שלא תוכנתה בשום מקום:
// steer() — the heart of the flock for (const o of flock) { const dx = o.x - this.x, dy = o.y - this.y; const d2 = dx*dx + dy*dy; // squared distance — no sqrt if (d2 < 10000) { // alignment + cohesion (100px) cx += o.x; cy += o.y; alx += o.vx; aly += o.vy; cn++; } if (d2 < 2500) { // separation (50px), inverse falloff const d = Math.sqrt(d2); sx -= dx/d * (50-d)/50; sy -= dy/d * (50-d)/50; } } ax += (cx/cn - this.x) * .0022 // cohesion — weakest + (alx/cn) * .05 // alignment — medium + sx * .22; // separation — strongest
נוספו שני כוחות "רכים": שיטוט (סינוס איטי על זווית פנימית, כדי שהלהקה לא תקפא) וקירות רכים — דחיפה פרופורציונלית כשמתקרבים לשוליים, במקום היפוך כיוון קשיח. כשמופיע כוכב־מאכל בטווח 340px, נוסף כוח חיפוש שגובר על הכול והדג מאיץ פי 1.5.
דג אינו נקודה — הוא שרשרת של 14 חוליות. הראש נע לפי הפיזיקה של הבואיד, וכל חוליה פשוט "נגררת" אחרי קודמתה במרחק קבוע. שתי שורות מתמטיקה מייצרות תנועת שחייה משכנעת להפליא:
// each vertebra keeps a fixed gap from the previous one for (let i = 1; i < SEG; i++) { const p = spine[i], q = spine[i-1]; const d = Math.hypot(p.x-q.x, p.y-q.y); p.x = q.x + (p.x-q.x)/d * gap; // pull toward parent p.y = q.y + (p.y-q.y)/d * gap; } // swim wave: sideways sine, growing toward the tail const wave = Math.sin(phase - i*.55) * (i/SEG) * 3.1*size;
הגוף עצמו הוא פוליגון אחד: לכל חוליה מחושב וקטור ניצב, ומוזזים ממנו שני קדקודים ימינה ושמאלה לפי פרופיל רוחב קבוע (צר בראש, רחב בכתפיים, מתחדד לזנב). כתמי הצבע של הקוי מצוירים בתוך ctx.clip() של אותו פוליגון — כך הם נעים ומתעוותים יחד עם הגוף. קצב הנפנוף (phase) מואץ לפי המהירות, so הדג "מתאמץ" כשהוא רודף אחרי אוכל.
הסצנה בנויה משלושה אלמנטים של <canvas> בערימה, כל אחד עם תפקיד ותדירות ציור משלו:
// motion trails: fade the layer toward TRANSPARENT (not black), // so the nebula behind stays visible through the trail sc.globalCompositeOperation = 'destination-out'; sc.fillStyle = 'rgba(0,0,0,0.16)'; sc.fillRect(0, 0, W, H); sc.globalCompositeOperation = 'source-over';
הזוהר של כל דג, של כוכבי המאכל ושל החלקיקים מצויר ב-globalCompositeOperation='lighter' — חיבור אדיטיבי של אור, כמו חשיפה כפולה בצילום. זה מה שהופך משטח שטוח ל"ליל". אין אף shadowBlur בכל הפרויקט — הוא יקר; כל הזוהר הוא גרדיאנטים רדיאליים זולים.
לולאת המשוב המלאה, צעד אחר צעד:
glowPulse שדועך אקספוננציאלית).// the eaten star ascends and becomes a permanent sky star const u = Math.min(fl.t / fl.dur, 1); const e = 1 - Math.pow(1 - u, 3); // easeOutCubic const x = fl.sx + (fl.tx - fl.sx) * e; const y = fl.sy + (fl.ty - fl.sy) * e - Math.sin(u * Math.PI) * 60; if (u >= 1) skyStars.push({ x: fl.tx, y: fl.ty, born: true, ... });
אם המבקר לא לחץ תוך 3.2 שניות, המערכת מפזרת כוכב־הדגמה אחד בעצמה — כדי שאיש לא יפספס את הקסם.
הצליל כבוי כברירת מחדל ומופעל רק מכפתור גלוי (דרישת הדפדפנים וגם נימוס בסיסי). אין קובצי אודיו — כל תו הוא אוסצילטור סינוס עם מעטפת:
note(f, dur, vol) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = f; // pentatonic: C D E G A
g.gain.linearRampToValueAtTime(vol, t + .015); // fast attack
g.gain.exponentialRampToValueAtTime(.0001, t + dur); // long decay
}
פיזור כוכב = תו נמוך אקראי מהסולם; אכילה = תו גבוה + אוקטבה חלשה מעליו (כמו פעמון). סולם פנטטוני נבחר כי כל צירוף אקראי שלו נשמע נכון — המבקר מנגן מוזיקה בלי לדעת.
פלטת "ליל זהב" — שישה ערכים, מצב רוח אחד:
Frank Ruhl Libre 900 לכותרת — סריף עברי כבד עם נוכחות של שער ספר, על גרדיאנט ירח→זהב. Heebo 300 לטקסט רץ — קל, אוורירי, לא מתחרה בשמיים. הכותרת נכנסת ב-cubic-bezier(.16,1,.3,1) (expo-out), הקנבס עולה ב-fade של 1.6 שניות רק אחרי הפריים הראשון — אין הבזק של דף ריק.
prefers-reduced-motion מכובד: מהירות הדגים יורדת ל-40%, הדעיכה מתקצרת, ואנימציות הכניסה מדלגות לסופן.
ההרחבה לאתר מלא נבנתה על עיקרון אחד: הקנבס לא זז — הדף זז מעליו. שלושת הקנבסים נשארים fixed, ו-<main> מקבל pointer-events:none בעוד שהפאנלים עצמם מחזירים pointer-events:auto. כך לחיצה ברווח שבין פאנלים עוברת דרך הדף אל קנבס הסצנה — והשמיים נשארים אינטראקטיביים לכל אורך הגלילה.
par (0.015–0.125). בכל פריים הכוכב מצויר ב-y - scrollY*par עם גלישה מודולרית — כוכבים קרובים נעים מהר, רחוקים כמעט קופאים, והשמיים מקבלים עומק אמיתי בגלילה.lighter באלפא זעיר (0.03–0.05), שמרכזיהם נודדים לאט על סינוסים. זולים לציור, והלילה מפסיק להיות סטטי.y:64→0 עם opacity, ואז כותרת/פסקה/כרטיסים ב-stagger. המספרים בסעיף האגדה נספרים מ-0 בכניסתם. אם הספרייה לא נטענת או ש-reduced-motion פעיל — הכול פשוט גלוי, בלי תלות.הלהקה החיה נשארה קוד טהור — אבל לסעיף "גלריית רגעים" נוצרו שלושה דימויים במחולל תמונות, בהכוונת פלטת האתר (לילה אינדיגו, זהב חם, קוי כתום־לבן). אלו הפרומפטים המלאים ששימשו:
העיקרון בניסוח: לחזור על מילות הפלטה בכל פרומפט (deep navy / indigo, warm gold, orange-and-white koi), לבקש "no text", ולתאר קומפוזיציה — לא רק נושא. התמונות נטענות ב-loading="lazy" ומוגבלות ל-400KB.
destination-out.pointer-events חכם, פרלקסה לפי עומק, ו-ScrollTrigger לכניסות.