Spaces:
Running
Running
Download index.html from BreadStudio/recommendation-pool: direct link, hf CLI and curl.
- Browser
- Download file 58.5 kB
-
https://huggingface.co/spaces/BreadStudio/recommendation-pool/resolve/main/index.html
- Command line
-
hf download hf://spaces/BreadStudio/recommendation-pool/index.html
-
curl -L -o index.html https://huggingface.co/spaces/BreadStudio/recommendation-pool/resolve/main/index.html
58.5 kB
| <meta charset="utf-8"> | |
| <title>The Recommendation Pool</title> | |
| <meta name="claude:viewport-fit-sides" content="cover"> | |
| <style> | |
| /* 版式:一张横放的方格草稿纸,整张画面就是那个循环;控件在纸下方。单一纸面风格,不随深浅色切换。 */ | |
| :root{ | |
| --paper:#fbfbf7; --grid:#dfe7ee; --ink:#262a33; --ink-soft:#6b7280; | |
| --cake:#e0484f; --pop:#f2c14e; --poop:#8a5a3b; --coin:#d9a520; --accent:#2f5fb3; --bad:#c2410c; | |
| --font:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Source Han Sans SC",system-ui,sans-serif; | |
| color-scheme:light; | |
| } | |
| html,body{background:var(--paper);color:var(--ink)} | |
| body{font-family:var(--font);padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:16px 28px} | |
| .wrap{max-width:1280px;margin:0 auto;display:flex;flex-direction:column;gap:12px} | |
| .scene{position:relative;width:100%;aspect-ratio:16/9;max-width:100%;border:1.5px solid var(--ink);border-radius:6px;overflow:hidden;background:var(--paper)} | |
| canvas{display:block;width:100%;height:100%} | |
| .controls{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center} | |
| .group{display:flex;flex-wrap:wrap;gap:6px;align-items:center} | |
| .lab{font-size:13px;color:var(--ink-soft);letter-spacing:.04em} | |
| button{font:inherit;font-size:14px;color:var(--ink);background:var(--paper);border:1.5px solid var(--ink);border-radius:999px;padding:6px 14px;cursor:pointer} | |
| button:hover{background:#eef2f7} | |
| button:focus-visible,input:focus-visible{outline:3px solid var(--accent);outline-offset:2px} | |
| button.primary{background:var(--ink);color:var(--paper)} | |
| button.primary:hover{background:#3b4150} | |
| button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:#fff} | |
| input[type=range]{width:220px;max-width:100%;accent-color:var(--accent)} | |
| output{font-variant-numeric:tabular-nums;font-weight:700;min-width:4.2em;display:inline-block} | |
| .note{font-size:12.5px;color:var(--ink-soft);line-height:1.6;max-width:72ch;margin:0} | |
| @media (max-width:640px){input[type=range]{width:160px}} | |
| </style> | |
| <div class="wrap"> | |
| <div class="scene"><canvas id="c" width="1600" height="900" aria-label="推荐池模拟画面"></canvas></div> | |
| <div class="controls"> | |
| <div class="group"> | |
| <button id="play" class="primary" type="button">开始</button> | |
| <button id="reset" type="button">重来</button> | |
| </div> | |
| <div class="group"> | |
| <span class="lab" id="kLab">AI 产能倍数</span> | |
| <input id="k" type="range" min="0" max="2" step="0.01" value="2" aria-labelledby="kLab"> | |
| <output id="kOut" for="k">×100</output> | |
| <span class="group" id="presets"></span> | |
| </div> | |
| <div class="group"> | |
| <span class="lab" id="spLab">速度</span> | |
| <button type="button" data-speed="0.5">慢</button> | |
| <button type="button" data-speed="1" aria-pressed="true">正常</button> | |
| <button type="button" data-speed="2.5">快</button> | |
| </div> | |
| <div class="group"><button id="lang" type="button">English</button></div> | |
| </div> | |
| <p class="note" id="note">示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。</p> | |
| </div> | |
| <script> | |
| // 推荐池演示:模拟核心(浏览器和命令行共用) | |
| // 一步 = 一天。筛选部分(三层试看)沿用 模拟.py 的参数和公式;其余为示意规则,未经实证校准。 | |
| (function (root) { | |
| "use strict"; | |
| // ---------- 写死的设定 ---------- | |
| const P = { | |
| // 筛选部分:与 模拟.py 相同 | |
| COMPLETION: { cake: 0.40, pop: 0.30, poop: 0.30 }, // 试看完播率:好内容 / 平庸内容 / 批量垃圾(试看表现和平庸内容一样,区别在用户看完会反感)。数值都取自论文图 1 | |
| CUT: 0.35, // 晋级线 | |
| TIERS: [100, 500, 2500], // 基线下三层每条试看人数 | |
| TRIAL_SHARE: 0.10, // 用户时间里用于试看的份额 | |
| // 用户 | |
| U0: 10000, // 一倍时的用户数 | |
| VIEWS_PER_USER: 60, // 每人每天看多少条(固定) | |
| JOY: { cake: 1.0, pop: 0.0, poop: -1.5 }, // 看一条带来的满意度 | |
| USER_REACT: 0.04, // 用户数每天向"目标人数"靠拢的速度 | |
| USER_MEMORY: 0.15, // 满意度的平滑:越小,用户记得越久 | |
| USER_SENS: 0.7, // 满意度每差 1,平台能留住的人数变化多少(相对一倍时) | |
| // 钱 | |
| AD_PRICE: 0.01, // 每次观看卖给广告商的价钱(元) | |
| CREATOR_SHARE: 0.30, // 分给创作者的比例;平台拿大头 | |
| COST_PER_ITEM: 0.1, // 每条内容的存储、分发、审核成本(元) | |
| COST_FIXED: 600, // 平台每天的固定开支(元) | |
| // 创作者:一倍时 | |
| RATE0: { cake: 0.2, pop: 0.5, poop: 0.2 }, // 每人每天做几条(做蛋糕的五天一条) | |
| ENTRY0: { cake: 0.27, pop: 13 }, // 每天进来试试的新人 | |
| WAGE: 22, // 每天至少要挣到多少才肯留下(元);做蛋糕的、做爆米花的、机器人都一样 | |
| PATIENCE: { cake: 30, pop: 30, poop: 20 }, // 连续挣不够多少天就走 | |
| RETIRE: 0.005, // 每天自然退出的比例 | |
| // AI 产能倍数 k 的作用 | |
| HUMAN_RATE_EXP: 0.65, // 真人每人产能 × k^0.65(要打磨,涨得少;一百倍时约二十倍) | |
| TALENT_EXP: 0.35, // 工具释放有能力的人:做蛋糕的新人 × k^0.35。两者相乘,蛋糕总量 × k(乐观设定,同论文"同比例增加") | |
| BOT_RATE_EXP: 1.0, // 做便便的:按账号算,每个号产能 × k(用 Boo 的数:不管质量,一百倍时一天二十条) | |
| BOT_COST_EXP: 0.5, // 每个号每天要挣回的成本 = 工钱 ÷ k^0.5:AI 越强,一个人能管的号越多,摊到每个号上越便宜 | |
| POOP_PER_K: 320, // 倍数每涨 1,做便便的号的"目标数"多 160 个(平台收入不变时) | |
| POOP_ARRIVE: 0.08, // 号数低于目标时,每天补上差距的 8% | |
| POOP_LEAVE: 0.03, // 号数高于目标时,每天走掉超出部分的 3% | |
| BOT_ENTRY_BASE: 1.0, // 有利可图时每天冒出来的机器人基数 | |
| BOT_COPY: 0.15, // 跟风:已有机器人越多,跟进得越快 | |
| CONVERT: 0.5, // 新机器人里,由做爆米花的人转行过来的比例 | |
| TOP_LIFE: 3, // 进了最上层的内容在推荐流里待几天 | |
| }; | |
| // 与 模拟.py 相同的晋级概率(正态近似;试看人数少于 1 按 1 计) | |
| function erf(x) { // Abramowitz–Stegun 7.1.26,误差 < 1.5e-7 | |
| const s = Math.sign(x); x = Math.abs(x); | |
| const t = 1 / (1 + 0.3275911 * x); | |
| const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x); | |
| return s * y; | |
| } | |
| function cdf(x) { return 0.5 * (1 + erf(x / Math.SQRT2)); } | |
| function passRate(p, n) { n = Math.max(n, 1); return 1 - cdf((P.CUT - p) / Math.sqrt(p * (1 - p) / n)); } | |
| function rng(seed) { // 可复现的随机数 | |
| let s = seed >>> 0 || 1; | |
| return function () { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; | |
| } | |
| const TYPES = ["cake", "pop", "poop"]; | |
| // 一倍时各层的流入量(每条新内容 = 1),用来确定每层的固定试看预算 | |
| const BASE_MIX = { cake: 0.05, pop: 0.95, poop: 0 }; | |
| const BASE_IN = (function () { | |
| let m = Object.assign({}, BASE_MIX); const ins = []; | |
| for (const n of P.TIERS) { | |
| ins.push(m.cake + m.pop + m.poop); | |
| m = { cake: m.cake * passRate(P.COMPLETION.cake, n), pop: m.pop * passRate(P.COMPLETION.pop, n), poop: 0 }; | |
| } | |
| return ins; | |
| })(); | |
| const TRIAL_VIEWS_PER_ITEM0 = P.TIERS.reduce((a, n, i) => a + n * BASE_IN[i], 0); // 一倍时每条新内容平均消耗的试看次数 | |
| const V0 = P.U0 * P.VIEWS_PER_USER * P.TRIAL_SHARE / TRIAL_VIEWS_PER_ITEM0; // 一倍时每天的新内容条数 | |
| // 一层层筛:给定今天各类新内容条数和用户数,返回各层流入、最上层产出、试看分布 | |
| function sieve(counts, users) { | |
| const budgetScale = users / P.U0; // 注意力 ∝ 用户数 | |
| let m = Object.assign({}, counts); | |
| const tiers = []; | |
| const trialViews = { cake: 0, pop: 0, poop: 0 }; | |
| for (let i = 0; i < P.TIERS.length; i++) { | |
| const inflow = m.cake + m.pop + m.poop; | |
| const budget = P.TIERS[i] * BASE_IN[i] * V0 * budgetScale; // 这一层每天的固定试看总量 | |
| const n = inflow > 0 ? budget / inflow : P.TIERS[i]; | |
| const next = {}; | |
| for (const t of TYPES) { trialViews[t] += m[t] * n; next[t] = m[t] * passRate(P.COMPLETION[t], n); } // 实际花掉的试看次数按预算分;晋级概率按 模拟.py 的口径(少于 1 人按 1 计) | |
| tiers.push({ inflow, n, mix: Object.assign({}, m) }); | |
| m = next; | |
| } | |
| return { tiers, top: m, trialViews }; | |
| } | |
| function makeWorld(opts) { | |
| opts = opts || {}; | |
| const R = rng(opts.seed || 7); | |
| const w = { | |
| day: 0, k: 1, kTarget: opts.k || 1, aiDay: opts.aiDay == null ? 20 : opts.aiDay, | |
| users: P.U0, sat: 0, creators: [], nextId: 1, | |
| topPool: [], // 每天进最上层的 {cake,pop,poop} | |
| log: [], events: { left: [], joined: [], usersLeft: 0, usersJoined: 0 }, | |
| today: null, R, | |
| }; | |
| // 一倍时的稳态人数 | |
| const nCake = Math.round(P.ENTRY0.cake / P.RETIRE * 1.0); | |
| const nPop = Math.round(P.ENTRY0.pop * P.PATIENCE.pop * 0.9); | |
| for (let i = 0; i < nCake; i++) w.creators.push(newCreator(w, "cake", Math.floor(R() * 200))); | |
| for (let i = 0; i < nPop; i++) { const c = newCreator(w, "pop", 0); c.broke = Math.floor(R() * P.PATIENCE.pop); w.creators.push(c); } | |
| // 预热:把最上层推荐流填成一倍时的样子 | |
| const warm = sieve({ cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 }, P.U0).top; | |
| for (let i = 0; i < P.TOP_LIFE; i++) w.topPool.push(Object.assign({}, warm)); | |
| w.sat = satisfaction(w, { cake: V0 * 0.05, pop: V0 * 0.95, poop: 0 }); | |
| w.sat0 = w.sat; | |
| w.rev0 = P.U0 * P.VIEWS_PER_USER * P.AD_PRICE; | |
| return w; | |
| } | |
| function newCreator(w, type, age) { | |
| // luck:真人是运气(收入 ×0.6~1.4);做便便的号是成本高低(成本 ×0.2~1.8,有的号几乎不花钱)。patience:耐心 ×0.5~1.5,大家不会同一天一起走 | |
| return { id: w.nextId++, type, age: age || 0, broke: 0, earned: 0, items: 0, luck: 0.6 + 0.8 * w.R(), costMul: 0.2 + 1.6 * w.R(), pat: 0.5 + w.R() }; | |
| } | |
| function feedMix(w) { | |
| const s = { cake: 0, pop: 0, poop: 0 }; | |
| for (const d of w.topPool) for (const t of TYPES) s[t] += d[t]; | |
| const tot = s.cake + s.pop + s.poop; | |
| return tot > 0 ? { cake: s.cake / tot, pop: s.pop / tot, poop: s.poop / tot, total: tot } : { cake: 0, pop: 0, poop: 0, total: 0 }; | |
| } | |
| function satisfaction(w, newCounts) { | |
| const f = feedMix(w); | |
| const nt = newCounts.cake + newCounts.pop + newCounts.poop; | |
| let s = 0; | |
| for (const t of TYPES) s += P.JOY[t] * ((1 - P.TRIAL_SHARE) * f[t] + P.TRIAL_SHARE * (nt > 0 ? newCounts[t] / nt : 0)); | |
| return s; | |
| } | |
| function step(w) { | |
| const R = w.R; | |
| w.day++; | |
| // AI 到来:第 aiDay 天起,倍数在两周内爬到目标值 | |
| if (w.day >= w.aiDay) w.k = Math.min(w.kTarget, w.k * Math.pow(w.kTarget, 1 / 14)); | |
| if (w.kTarget < w.k) w.k = w.kTarget; | |
| const k = w.k; | |
| const humanRate = Math.pow(k, P.HUMAN_RATE_EXP), talent = Math.pow(k, P.TALENT_EXP); | |
| // 1. 出东西 | |
| const made = { cake: 0, pop: 0, poop: 0 }; | |
| for (const c of w.creators) { | |
| const r = c.type === "poop" ? P.RATE0.poop * Math.pow(k, P.BOT_RATE_EXP) : P.RATE0[c.type] * humanRate; | |
| c.todayItems = r; made[c.type] += r; | |
| } | |
| // 2. 筛 | |
| const sv = sieve(made, w.users); | |
| w.topPool.push(sv.top); if (w.topPool.length > P.TOP_LIFE) w.topPool.shift(); | |
| // 3. 看:推荐流里各类内容的观看次数 | |
| const views = w.users * P.VIEWS_PER_USER; | |
| const feedViews = views * (1 - P.TRIAL_SHARE); | |
| const trialViews = views * P.TRIAL_SHARE; | |
| const fm = feedMix(w); | |
| // 每条新内容平均拿到的观看:试看 + 进了最上层后在推荐流里分到的 | |
| const topItems = fm.total; // 推荐流里同时在架的条数 | |
| const viewsPerTopItemPerDay = topItems > 0 ? feedViews / topItems : 0; | |
| const trialPerItem = {}, topProb = {}; | |
| for (const t of TYPES) { | |
| trialPerItem[t] = made[t] > 0 ? sv.trialViews[t] / made[t] : 0; | |
| topProb[t] = made[t] > 0 ? sv.top[t] / made[t] : 0; | |
| } | |
| // 4. 钱 | |
| const revenue = views * P.AD_PRICE; | |
| const pool = revenue * P.CREATOR_SHARE; | |
| const yuanPerView = pool / views; | |
| const cost = P.COST_FIXED + P.COST_PER_ITEM * (made.cake + made.pop + made.poop); | |
| const perItemIncome = {}; | |
| for (const t of TYPES) perItemIncome[t] = yuanPerView * (trialPerItem[t] + topProb[t] * viewsPerTopItemPerDay * P.TOP_LIFE); | |
| // 预期:如果有人做一条便便,它能挣多少(还没有机器人时也算得出来) | |
| const n1 = sv.tiers[0].n, n2 = sv.tiers[1].n, n3 = sv.tiers[2].n; | |
| const poopTopProb = passRate(P.COMPLETION.poop, n1) * passRate(P.COMPLETION.poop, n2) * passRate(P.COMPLETION.poop, n3); | |
| const poopIncomeProspect = made.poop > 0 ? perItemIncome.poop : yuanPerView * (Math.max(n1, 1) + poopTopProb * viewsPerTopItemPerDay * P.TOP_LIFE); | |
| // 5. 创作者去留 | |
| const left = [], joined = []; | |
| const keep = []; | |
| for (const c of w.creators) { | |
| c.age++; | |
| const inc = perItemIncome[c.type] * c.todayItems * (c.type === "poop" ? 1 : c.luck); | |
| c.earned = 0.8 * c.earned + 0.2 * inc; | |
| const need = c.type === "poop" ? c.costMul * P.WAGE / Math.pow(k, P.BOT_COST_EXP) : P.WAGE; | |
| if (inc < need) c.broke++; else c.broke = Math.max(0, c.broke - 2); | |
| let go = c.type !== "poop" && c.broke >= P.PATIENCE[c.type] * c.pat; | |
| if (c.type !== "poop" && R() < P.RETIRE) go = true; | |
| if (go) left.push(c); else keep.push(c); | |
| } | |
| w.creators = keep; | |
| // 新人 | |
| const add = (type, expected) => { let n = Math.floor(expected); if (R() < expected - n) n++; for (let i = 0; i < n; i++) { const c = newCreator(w, type, 0); w.creators.push(c); joined.push(c); } }; | |
| add("cake", P.ENTRY0.cake * talent); | |
| add("pop", P.ENTRY0.pop); | |
| // 做便便的:成本几乎为零、买彩票心态,人数主要取决于 AI 让批量生产变得多容易(同 模拟.py:新增的量是批量内容), | |
| // 只温和地跟着钱走:平台收入越少,来的人越少,但不会走光。一部分是做爆米花的人"装上天线"转过来的 | |
| const nBots = w.creators.reduce((a, c) => a + (c.type === "poop"), 0); | |
| const botTarget = P.POOP_PER_K * (k - 1) * Math.sqrt(Math.max(0.02, revenue / w.rev0)); | |
| w.converted = []; | |
| const gap = botTarget - nBots; | |
| if (gap > 0) { | |
| let n = Math.floor(gap * P.POOP_ARRIVE); if (R() < gap * P.POOP_ARRIVE - n) n++; | |
| const pops = w.creators.filter(c => c.type === "pop"); let convLeft = Math.floor(pops.length * 0.02); | |
| for (let i = 0; i < n; i++) { | |
| if (convLeft > 0 && R() < P.CONVERT) { const c = pops[Math.floor(R() * pops.length)]; if (c.type === "pop") { c.type = "poop"; w.converted.push(c); convLeft--; continue; } } | |
| const c = newCreator(w, "poop", 0); w.creators.push(c); joined.push(c); | |
| } | |
| } else if (gap < 0) { | |
| let n = Math.floor(-gap * P.POOP_LEAVE); if (R() < -gap * P.POOP_LEAVE - n) n++; | |
| if (n > 0) { const out = new Set(); const bots = w.creators.filter(c => c.type === "poop"); | |
| while (out.size < Math.min(n, bots.length)) out.add(bots[Math.floor(R() * bots.length)]); | |
| w.creators = w.creators.filter(c => !out.has(c)); for (const c of out) left.push(c); } | |
| } | |
| // 6. 用户去留 | |
| const sToday = satisfaction(w, made); | |
| w.sat = (1 - P.USER_MEMORY) * w.sat + P.USER_MEMORY * sToday; | |
| // 用户忍耐度不一:满意度每差 1,留下的人按比例少一截,越往后剩下的越是不挑的人,走得越慢 | |
| const target = P.U0 * Math.exp(P.USER_SENS * (w.sat - w.sat0)); | |
| const before = w.users; | |
| w.users = w.users + P.USER_REACT * (target - w.users); | |
| const count = { cake: 0, pop: 0, poop: 0 }; | |
| for (const c of w.creators) count[c.type]++; | |
| w.today = { | |
| day: w.day, k, made, sv, fm, revenue, cost, pool, users: w.users, sat: w.sat, | |
| count, perItemIncome, botTarget, poopIncomeProspect, converted: w.converted, | |
| cakeShareTop: fm.cake, left, joined, usersDelta: w.users - before, | |
| }; | |
| w.log.push({ day: w.day, k, users: w.users, revenue, cost, cakeTop: fm.cake, poopTop: fm.poop, nCake: count.cake, nPop: count.pop, nBot: count.poop, items: made.cake + made.pop + made.poop, n1 }); | |
| return w.today; | |
| } | |
| const API = { P, makeWorld, step, sieve, passRate, V0, TYPES }; | |
| if (typeof module !== "undefined" && module.exports) module.exports = API; else root.PoolSim = API; | |
| })(typeof window !== "undefined" ? window : globalThis); | |
| </script> | |
| <script> | |
| (function(){ | |
| ; | |
| const S = window.PoolSim, P = S.P; | |
| const cv = document.getElementById("c"), ctx = cv.getContext("2d"); | |
| const W = 1600, H = 900; | |
| const DPR = Math.min(2, window.devicePixelRatio || 1); cv.width = W * DPR; cv.height = H * DPR; | |
| const FONT = getComputedStyle(document.documentElement).getPropertyValue("--font"); | |
| const C = { paper:"#fbfbf7", grid:"#dfe7ee", ink:"#262a33", soft:"#6b7280", cake:"#e0484f", cream:"#fff6f2", pink:"#f4a6b0", pop:"#f2c14e", popHi:"#fff1c4", poop:"#8a5a3b", poopHi:"#a87452", coin:"#d9a520", coinHi:"#f7d774", accent:"#2f5fb3", bad:"#c2410c", good:"#2f8f5b", leaf:"#3f9a4a", strip:"#ffffffee" }; | |
| const REDUCED = matchMedia("(prefers-reduced-motion: reduce)").matches; | |
| // ---------- 文字 ---------- | |
| const T = { | |
| zh:{ title:"推荐池", sub:"免费看 · 广告养 · 分成活", ai:"AI 产能", day:"第 {d} 天", | |
| m:["用户","广告收入 / 天","平台成本 / 天","推荐池里的蛋糕"], unitYuan:"元", | |
| stage:"创作者", cake:"做蛋糕的", pop:"做爆米花的", bot:"做便便的", stageNote:"台上小人按比例代表全部创作者(做便便的按账号算)", | |
| tier:["第一层","第二层","第三层"], perItem:"每条 {n} 人试看", rejected:"没被推荐", feed:"推荐流", plaza:"用户", | |
| plazaNote:"一个点 = 50 位用户", adv:"广告商", vault:"平台", keep:"净赚 {v} 元/天", lose:"亏 {v} 元/天", | |
| server:"存储·分发·审核", toCreators:"分给创作者 {v} 元/天", adIn:"广告费 {v} 元/天", attention:"用户的时间,卖给广告商", | |
| play:"开始", pause:"暂停", resume:"继续", reset:"重来", kLab:"AI 产能倍数", spLab:"速度", slow:"慢", normal:"正常", fast:"快", lang:"English", | |
| note:"示意模型。筛选部分(三层试看、完播率、晋级线)沿用论文图 1 的参数;用户去留、分成、成本、创作者去留等循环部分的规则与参数未经实证校准,只用来展示方向。", | |
| cap:{ intro:"一年前,AI 还没来。好内容被一层层试看挑出来,用户吃得开心。", ready:"拧好 AI 产能倍数,按「开始」。", | |
| ai:"AI 来了:每个人的产能 ×{k}", bot:"装天线的来了:批量做,划算了。", flood:"推荐池里混进了大量便便。", | |
| users:"用户开始走了,广告费跟着少了。", chefs:"做蛋糕的人挣不到钱,背上包走了。", botsGone:"钱少了,做便便的退了一批,剩下的赖着不走。", | |
| popFlood:"爆米花挤满了推荐流,好内容被淹没。", calm:"产能涨了,推荐池还挑得出来。", | |
| half:"上传的东西一半是便便,筛子还挡得住。", lottery:"每条只剩一两个人试看,筛子成了抽签。" }, | |
| endFail:"内容多了 {k} 倍,推荐池挑不出好内容了", endOk:"{k} 倍时,推荐池还挑得出好内容", endHead:"第 365 天", from:"一年前", to:"现在", again:"按「重来」再看一遍,或者换个倍数。" }, | |
| en:{ title:"The Recommendation Pool", sub:"Free to watch · ad-funded · revenue-shared", ai:"AI output", day:"Day {d}", | |
| m:["Users","Ad revenue / day","Platform cost / day","Cake in the pool"], unitYuan:"¥", | |
| stage:"Creators", cake:"Cake", pop:"Popcorn", bot:"Poop", stageNote:"Figures show the creator mix in proportion (poop makers counted by account)", | |
| tier:["Tier 1","Tier 2","Tier 3"], perItem:"{n} trial viewers each", rejected:"Not recommended", feed:"Feed", plaza:"Users", | |
| plazaNote:"one dot = 50 users", adv:"Advertisers", vault:"Platform", keep:"Nets ¥{v}/day", lose:"Loses ¥{v}/day", | |
| server:"Storage · delivery · review", toCreators:"To creators ¥{v}/day", adIn:"Ad spend ¥{v}/day", attention:"Users' time, sold to advertisers", | |
| play:"Start", pause:"Pause", resume:"Resume", reset:"Restart", kLab:"AI output multiple", spLab:"Speed", slow:"Slow", normal:"Normal", fast:"Fast", lang:"中文", | |
| note:"Illustrative model. The selection part (three trial tiers, completion rates, cut-off) uses the parameters of Figure 1 in the paper; the loop part (users, revenue share, cost, creators coming and going) uses rules and parameters that are not empirically calibrated and only show the direction.", | |
| cap:{ intro:"A year ago, before AI. Good content is picked out tier by tier, and users are happy.", ready:"Set the AI output multiple, then press Start.", | |
| ai:"AI arrives: everyone's output ×{k}", bot:"Antennas show up: batch production now pays.", flood:"The pool is filling up with poop.", | |
| users:"Users start leaving, and ad money leaves with them.", chefs:"Cake makers can't earn a living and pack up.", botsGone:"The money dries up. Some poop makers quit; the rest stay put.", | |
| popFlood:"Popcorn crowds the feed and good content gets buried.", calm:"Output is up, and the pool still picks well.", | |
| half:"Half of all uploads are poop. The sieve still holds.", lottery:"One or two trial viewers per item: the sieve has become a lottery." }, | |
| endFail:"With {k}× the content, the pool can no longer pick out good content", endOk:"At {k}×, the pool still picks out good content", endHead:"Day 365", from:"A year ago", to:"Now", again:"Press Restart to watch again, or try another multiple." }, | |
| }; | |
| let lang = "zh"; | |
| const tr = (key, vars) => { let s = key.split(".").reduce((o, k) => o[k], T[lang]); if (vars) for (const k in vars) s = s.replace("{" + k + "}", vars[k]); return s; }; | |
| const fmtInt = v => Math.round(v).toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); | |
| const fmtK = k => k >= 10 ? Math.round(k).toString() : (Math.round(k * 10) / 10).toString(); | |
| // ---------- 手绘工具 ---------- | |
| function hash(n){ n = (n ^ 61) ^ (n >>> 16); n = n + (n << 3); n = n ^ (n >>> 4); n = Math.imul(n, 0x27d4eb2d); n = n ^ (n >>> 15); return (n >>> 0) / 4294967296; } | |
| function jit(seed, a){ return (hash(seed) - 0.5) * 2 * a; } | |
| function line(x1,y1,x2,y2,seed,w,col,a){ a = a == null ? 1.2 : a; ctx.strokeStyle = col || C.ink; ctx.lineWidth = w || 2; ctx.beginPath(); ctx.moveTo(x1 + jit(seed,a), y1 + jit(seed+1,a)); const mx = (x1+x2)/2 + jit(seed+2,a*1.5), my = (y1+y2)/2 + jit(seed+3,a*1.5); ctx.quadraticCurveTo(mx, my, x2 + jit(seed+4,a), y2 + jit(seed+5,a)); ctx.stroke(); } | |
| function rrect(x,y,w,h,seed,fill,stroke,lw){ ctx.beginPath(); const a = 1.4; ctx.moveTo(x + jit(seed,a), y + jit(seed+1,a)); ctx.lineTo(x + w + jit(seed+2,a), y + jit(seed+3,a)); ctx.lineTo(x + w + jit(seed+4,a), y + h + jit(seed+5,a)); ctx.lineTo(x + jit(seed+6,a), y + h + jit(seed+7,a)); ctx.closePath(); if (fill){ ctx.fillStyle = fill; ctx.fill(); } if (stroke !== false){ ctx.strokeStyle = stroke || C.ink; ctx.lineWidth = lw || 2; ctx.stroke(); } } | |
| function circ(x,y,r,fill,stroke,lw){ ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2); if (fill){ ctx.fillStyle = fill; ctx.fill(); } if (stroke !== false){ ctx.strokeStyle = stroke || C.ink; ctx.lineWidth = lw || 2; ctx.stroke(); } } | |
| function text(s,x,y,size,col,align,weight,base){ ctx.font = (weight||400) + " " + size + "px " + FONT; ctx.fillStyle = col || C.ink; ctx.textAlign = align || "left"; ctx.textBaseline = base || "alphabetic"; ctx.fillText(s,x,y); } | |
| function textW(s,size,weight){ ctx.font = (weight||400) + " " + size + "px " + FONT; return ctx.measureText(s).width; } | |
| // ---------- 三种东西 ---------- | |
| function drawItem(type,x,y,s,alpha){ | |
| ctx.save(); ctx.globalAlpha = alpha == null ? 1 : alpha; ctx.translate(x,y); | |
| const lw = Math.max(1, s * 0.09); | |
| if (type === "cake"){ | |
| ctx.beginPath(); ctx.moveTo(-s*0.5, s*0.32); ctx.lineTo(s*0.5, s*0.32); ctx.lineTo(s*0.42,-s*0.12); ctx.lineTo(-s*0.42,-s*0.12); ctx.closePath(); | |
| ctx.fillStyle = C.cream; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = lw; ctx.stroke(); | |
| ctx.fillStyle = C.pink; ctx.fillRect(-s*0.46, s*0.06, s*0.92, s*0.09); | |
| circ(0, -s*0.26, s*0.2, C.cake, C.ink, lw); | |
| ctx.fillStyle = C.leaf; ctx.beginPath(); ctx.ellipse(0, -s*0.47, s*0.12, s*0.05, 0, 0, Math.PI*2); ctx.fill(); | |
| } else if (type === "pop"){ | |
| const pts = [[-0.22,0.12],[0.2,0.14],[0,-0.1],[-0.25,-0.18],[0.24,-0.16],[0.02,0.3]]; | |
| for (const [px,py] of pts){ circ(px*s, py*s, s*0.2, C.pop, C.ink, lw); } | |
| circ(-s*0.05, -s*0.14, s*0.07, C.popHi, false); | |
| } else { | |
| ctx.fillStyle = C.poop; ctx.strokeStyle = C.ink; ctx.lineWidth = lw; | |
| ctx.beginPath(); ctx.ellipse(0, s*0.22, s*0.46, s*0.16, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke(); | |
| ctx.beginPath(); ctx.ellipse(0, s*0.02, s*0.34, s*0.14, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke(); | |
| ctx.beginPath(); ctx.ellipse(0, -s*0.16, s*0.22, s*0.12, 0, 0, Math.PI*2); ctx.fill(); ctx.stroke(); | |
| ctx.beginPath(); ctx.moveTo(-s*0.04,-s*0.27); ctx.quadraticCurveTo(s*0.08,-s*0.42, s*0.14,-s*0.46); ctx.lineTo(s*0.02,-s*0.27); ctx.closePath(); ctx.fill(); ctx.stroke(); | |
| if (s >= 18){ circ(-s*0.13, s*0.0, s*0.08, "#fff", C.ink, lw*0.7); circ(s*0.13, s*0.0, s*0.08, "#fff", C.ink, lw*0.7); circ(-s*0.12, s*0.01, s*0.035, C.ink, false); circ(s*0.14, s*0.01, s*0.035, C.ink, false); | |
| ctx.beginPath(); ctx.arc(0, s*0.14, s*0.08, 0.15*Math.PI, 0.85*Math.PI); ctx.lineWidth = lw*0.7; ctx.stroke(); } | |
| } | |
| ctx.restore(); | |
| } | |
| // ---------- 小人 ---------- | |
| function drawPerson(x,y,type,opt){ | |
| opt = opt || {}; const seed = opt.seed || 1, mood = opt.mood == null ? 1 : opt.mood; | |
| ctx.save(); ctx.translate(x,y); if (opt.flip) ctx.scale(-1,1); ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha; | |
| const lw = 2.2; | |
| const walk = opt.walk || 0; | |
| // 腿 | |
| line(0,-6, -7 + Math.sin(walk)*6, 14, seed, lw); line(0,-6, 7 - Math.sin(walk)*6, 14, seed+9, lw); | |
| // 身子 | |
| line(0,-30, 0,-6, seed+20, lw); | |
| // 背包 | |
| if (opt.pack){ rrect(-16,-30,11,18,seed+40,"#c9b48a",C.ink,1.8); } | |
| // 左手:摊开 | |
| line(0,-24, -16,-16, seed+30, lw); ctx.beginPath(); ctx.arc(-19,-17,4,Math.PI*0.6,Math.PI*1.7); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.8; ctx.stroke(); | |
| // 右手:往前推 | |
| const push = opt.push == null ? 0 : opt.push, blur = opt.blur || 0; | |
| if (blur > 0){ for (let i = 0; i < 4; i++){ const p = i/3; ctx.globalAlpha = (opt.alpha==null?1:opt.alpha) * 0.25; line(0,-24, 6 + 16*p, -24 + 6*(1-p), seed+50+i, lw); } ctx.globalAlpha = opt.alpha == null ? 1 : opt.alpha; | |
| ctx.strokeStyle = C.soft; ctx.lineWidth = 1.2; for (let i = 0; i < 3; i++){ ctx.beginPath(); ctx.moveTo(26, -26 + i*5); ctx.lineTo(34, -26 + i*5); ctx.stroke(); } } | |
| else { line(0,-24, 8 + 14*push, -22 + 4*push, seed+60, lw); } | |
| // 头 | |
| const headFill = type === "poop" ? "#eef1f6" : "#ffffff"; | |
| circ(0,-44, 14, headFill, C.ink, lw); | |
| if (type === "poop"){ ctx.fillStyle = C.ink; ctx.fillRect(-7,-48,4,4); ctx.fillRect(3,-48,4,4); line(-5,-38,5,-38,seed+70,1.6,C.ink,0.3); } | |
| else { circ(-5,-46,1.7,C.ink,false); circ(5,-46,1.7,C.ink,false); ctx.beginPath(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.6; | |
| if (mood > 0.5) ctx.arc(0,-41,5,0.15*Math.PI,0.85*Math.PI); else if (mood > -0.2){ ctx.moveTo(-4,-38); ctx.lineTo(4,-38); } else ctx.arc(0,-34,5,1.15*Math.PI,1.85*Math.PI); ctx.stroke(); } | |
| // 头顶 | |
| if (type === "cake"){ ctx.beginPath(); ctx.fillStyle = "#fff"; ctx.strokeStyle = C.ink; ctx.lineWidth = 1.8; ctx.rect(-10,-64,20,7); ctx.fill(); ctx.stroke(); | |
| circ(-7,-69,6,"#fff",C.ink,1.8); circ(0,-72,7,"#fff",C.ink,1.8); circ(7,-69,6,"#fff",C.ink,1.8); ctx.fillStyle = "#fff"; ctx.fillRect(-9,-67,18,5); } | |
| if (type === "poop"){ line(0,-58,2,-72,seed+80,1.8); const blink = opt.blink ? C.cake : "#f08a8f"; circ(2,-75,3.5,blink,C.ink,1.4); } | |
| if (opt.spark){ ctx.strokeStyle = C.coin; ctx.lineWidth = 2; for (let i = 0; i < 6; i++){ const a = i/6*Math.PI*2; ctx.beginPath(); ctx.moveTo(2+Math.cos(a)*8,-75+Math.sin(a)*8); ctx.lineTo(2+Math.cos(a)*14,-75+Math.sin(a)*14); ctx.stroke(); } } | |
| ctx.restore(); | |
| } | |
| // ---------- 版面坐标 ---------- | |
| const L = { | |
| stage:{ x:28, y:150, w:450, h:500 }, | |
| slots:[], // 填在下面 | |
| sieve:[ {x:730,y:225}, {x:730,y:385}, {x:730,y:545} ], bowlW:240, | |
| bin:{ x:730, y:640 }, | |
| feed:{ x1:1060, x2:1572, y:598 }, | |
| plaza:{ x:1040, y:150, w:540, h:420 }, | |
| adv:{ x:1500, y:810 }, vault:{ x:830, y:800 }, server:{ x:1050, y:820 }, | |
| }; | |
| const COLS = 6, ROWS = 5; | |
| for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) L.slots.push({ x: 72 + c*66 + (r%2)*14, y: 262 + r*76 }); | |
| const NSLOT = L.slots.length; | |
| // ---------- 状态 ---------- | |
| let world, today, kSel = 100, running = false, started = false, ended = false, speed = 1, dayAcc = 0; | |
| const DAY_SEC = 0.16; | |
| let slots = [], walkers = [], items = [], pops = [], coins = [], dots = [], reacts = [], hist = [], belt = []; | |
| let caps = [], capShown = null, capT = 0, flags = {}, peakBots = 0, startStats = null, endStats = null; | |
| function reset(){ | |
| world = S.makeWorld({ k: kSel, seed: 7 }); world.kTarget = kSel; | |
| today = probeToday(); | |
| running = false; started = false; ended = false; dayAcc = 0; | |
| slots = []; walkers = []; items = []; pops = []; coins = []; dots = []; reacts = []; hist = []; belt = []; | |
| caps = []; capShown = { key:"intro" }; capT = 0; flags = {}; peakBots = 0; endStats = null; | |
| layoutSlots(true); layoutDots(true); | |
| startStats = { users: P.U0, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop }; | |
| pushHist(today); | |
| setPlayLabel(); | |
| } | |
| // 一倍时的"今天":复制世界跑一步拿数,不推进真正的世界 | |
| function probeToday(){ | |
| const w2 = S.makeWorld({ k: 1, seed: 7 }); w2.kTarget = 1; return S.step(w2); | |
| } | |
| function pushHist(t){ hist.push({ users:t.users, revenue:t.revenue, cost:t.cost, cake:t.cakeShareTop }); } | |
| // 台上的小人:按人数比例分 30 个位子,有人就至少 1 个位子 | |
| function targetSlots(count){ | |
| const tot = count.cake + count.pop + count.poop || 1; const types = ["cake","pop","poop"]; const out = {}; let used = 0; const rem = []; | |
| for (const t of types){ const ex = count[t] / tot * NSLOT; out[t] = Math.floor(ex); if (count[t] > 0 && out[t] === 0) out[t] = 1; used += out[t]; rem.push([ex - Math.floor(ex), t]); } | |
| rem.sort((a,b) => b[0]-a[0]); let i = 0; | |
| while (used < NSLOT){ out[rem[i % 3][1]]++; used++; i++; } | |
| while (used > NSLOT){ const t = types.slice().sort((a,b) => out[b]-out[a])[0]; out[t]--; used--; } | |
| return out; | |
| } | |
| function layoutSlots(instant, converted){ | |
| const count = today ? today.count : world.creators.reduce((o,c) => (o[c.type]++, o), {cake:0,pop:0,poop:0}); | |
| const tgt = targetSlots(count); | |
| if (instant || slots.length === 0){ | |
| const order = []; for (const t of ["cake","pop","poop"]) for (let i = 0; i < tgt[t]; i++) order.push(t); | |
| // 打散,让三种人混站 | |
| for (let i = order.length - 1; i > 0; i--){ const j = Math.floor(hash(i*7+3) * (i+1)); [order[i],order[j]] = [order[j],order[i]]; } | |
| slots = order.map((t,i) => ({ type:t, seed:i*31+5, phase:hash(i*13), pop:1, born:-9 })); | |
| return; | |
| } | |
| const have = { cake:0, pop:0, poop:0 }; for (const s of slots) have[s.type]++; | |
| let conv = converted || 0; | |
| // 先让做爆米花的就地装天线 | |
| while (conv > 0 && have.poop < tgt.poop && have.pop > tgt.pop){ | |
| const cand = slots.filter(s => s.type === "pop"); if (!cand.length) break; const s = cand[Math.floor(Math.random()*cand.length)]; | |
| s.type = "poop"; s.spark = 1.2; have.pop--; have.poop++; conv--; | |
| } | |
| for (const t of ["cake","pop","poop"]){ | |
| while (have[t] > tgt[t]){ | |
| const cand = slots.map((s,i) => [s,i]).filter(([s]) => s.type === t && !s.leaving); if (!cand.length) break; | |
| const [s,i] = cand[Math.floor(Math.random()*cand.length)]; | |
| walkers.push({ type:t, x:L.slots[i].x, y:L.slots[i].y, seed:s.seed, t:0 }); | |
| s.type = null; have[t]--; | |
| } | |
| } | |
| const empty = slots.filter(s => s.type === null); | |
| const need = []; for (const t of ["cake","pop","poop"]) for (let i = have[t]; i < tgt[t]; i++) need.push(t); | |
| for (let i = 0; i < empty.length; i++){ const t = need[i] || "pop"; empty[i].type = t; empty[i].pop = 0; empty[i].born = 0; } | |
| } | |
| // 用户的点 | |
| function layoutDots(instant){ | |
| const n = Math.max(0, Math.round((today ? today.users : P.U0) / 50)); | |
| const cols = 20, cw = 26, rh = 38; | |
| while (dots.filter(d => !d.leaving).length > n){ | |
| const live = dots.filter(d => !d.leaving); const d = live[Math.floor(Math.random()*live.length)]; d.leaving = true; | |
| } | |
| let live = dots.filter(d => !d.leaving).length; | |
| while (live < n){ | |
| // 找空位 | |
| const used = new Set(dots.filter(d => !d.leaving).map(d => d.slot)); let slot = 0; while (used.has(slot)) slot++; | |
| const cx = L.plaza.x + 22 + (slot % cols) * cw + (Math.floor(slot/cols)%2) * 9, cy = L.plaza.y + 56 + Math.floor(slot/cols) * rh; | |
| dots.push({ slot, hx:cx, hy:cy, x: instant ? cx : W + 30 + Math.random()*120, y: instant ? cy : cy, off: Math.random()*1.2 - 0.6, seed: slot*17+3 }); | |
| live++; | |
| } | |
| } | |
| // ---------- 一天 ---------- | |
| function advanceDay(){ | |
| if (world.day >= 365){ finish(); return; } | |
| today = S.step(world); | |
| pushHist(today); | |
| layoutSlots(false, (today.converted || []).length); | |
| layoutDots(false); | |
| events(); | |
| if (world.day >= 365) finish(); | |
| } | |
| function queue(key, vars){ if (flags[key]) return; flags[key] = true; caps = [{ key, vars }]; } // 只留最新的一条 | |
| function events(){ | |
| const t = today, d = world.day; | |
| peakBots = Math.max(peakBots, t.count.poop); | |
| if (d === world.aiDay) queue("ai", { k: fmtK(kSel) }); | |
| if (t.count.poop > 0) queue("bot"); | |
| { const m = t.made, up = m.poop / (m.cake + m.pop + m.poop || 1); if (up > 0.4 && t.fm.poop < 0.1) queue("half"); } | |
| if (t.sv.tiers[0].n < 2 && d > world.aiDay) queue("lottery"); | |
| if (t.fm.poop > 0.25) queue("flood"); | |
| if (t.users < 0.85 * P.U0) queue("users"); | |
| if (t.count.cake < 30 && d > world.aiDay) queue("chefs"); | |
| if (peakBots >= 10 && t.count.poop < peakBots * 0.3) queue("botsGone"); | |
| if (t.fm.pop > 0.5) queue("popFlood"); | |
| if (d === world.aiDay + 80 && t.users > 0.9 * P.U0 && t.cakeShareTop > 0.8) queue("calm"); | |
| } | |
| function finish(){ | |
| running = false; ended = true; setPlayLabel(); | |
| endStats = { users: today.users, revenue: today.revenue, cost: today.cost, cake: today.cakeShareTop }; | |
| } | |
| // ---------- 动画 ---------- | |
| function stageTarget(){ return { x: L.sieve[0].x - 40 + Math.random()*80, y: L.sieve[0].y - 22 }; } | |
| function tierLoad(i){ const n = today ? today.sv.tiers[i].n : P.TIERS[i]; return P.TIERS[i] / Math.max(n, 0.5); } | |
| function emitFrom(i){ | |
| const s = slots[i]; if (!s.type) return; | |
| const p = L.slots[i]; const tg = stageTarget(); | |
| if (items.length > 520) return; | |
| items.push({ type:s.type, st:"fly", x:p.x+24, y:p.y-22, sx:p.x+24, sy:p.y-22, tx:tg.x, ty:tg.y, t:0, dur:0.9 + Math.random()*0.35, tier:0, rot:Math.random()*6 }); | |
| } | |
| function personRate(type){ const k = today ? today.k : 1; return type === "poop" ? P.RATE0.poop * Math.pow(k, P.BOT_RATE_EXP) : P.RATE0[type] * Math.pow(k, P.HUMAN_RATE_EXP); } | |
| const VIS = 0.43; // 每天 1 条 ≈ 每秒 0.43 次推 | |
| function update(dt){ | |
| const simDt = dt * speed; | |
| if (running && !ended){ dayAcc += simDt; while (dayAcc >= DAY_SEC){ dayAcc -= DAY_SEC; advanceDay(); if (ended) break; } } | |
| // 推 | |
| for (let i = 0; i < slots.length; i++){ | |
| const s = slots[i]; if (!s.type) continue; | |
| s.pop = Math.min(1, s.pop + dt*2.5); if (s.spark) s.spark = Math.max(0, s.spark - dt); | |
| const f = personRate(s.type) * VIS; s.f = f; | |
| s.phase += dt * f; | |
| while (s.phase >= 1){ s.phase -= 1; if (Math.random() < 1) emitFrom(i); } | |
| } | |
| // 东西 | |
| pileCount = [0,0,0]; for (const it of items) if (it.st === "pile") pileCount[it.tier]++; | |
| const fall = []; | |
| for (const it of items){ | |
| it.t += dt; | |
| if (it.st === "fly"){ const u = Math.min(1, it.t/it.dur); it.x = it.sx + (it.tx-it.sx)*u; it.y = it.sy + (it.ty-it.sy)*u - Math.sin(u*Math.PI)*45; if (u >= 1){ it.st = "pile"; it.t = 0; it.dwell = Math.min(4, 0.45 + 0.4*Math.log2(Math.max(1, tierLoad(0)))) * (0.7 + Math.random()*0.6); placeOnPile(it, 0); } } | |
| else if (it.st === "pile"){ if (it.t >= it.dwell){ judge(it); } } | |
| else if (it.st === "drop"){ it.vy += 900*dt; it.y += it.vy*dt; it.x += it.vx*dt; | |
| if (it.next != null && it.y >= L.sieve[it.next].y - 18){ const tier = it.next; it.st = "pile"; it.t = 0; it.tier = tier; it.dwell = Math.min(4, 0.45 + 0.4*Math.log2(Math.max(1, tierLoad(tier)))) * (0.7 + Math.random()*0.6); placeOnPile(it, tier); } | |
| else if (it.next == null && it.y >= L.bin.y - 6){ it.st = "dead"; binCount[it.type] = (binCount[it.type]||0) + 1; } } | |
| else if (it.st === "chute"){ const u = Math.min(1, it.t/0.9); it.x = it.sx + (L.feed.x1 - it.sx)*u; it.y = it.sy + (L.feed.y - it.sy)*u; if (u >= 1){ it.st = "dead"; } } | |
| else if (it.st === "feed"){ it.x += dt * 70; if (it.x > L.feed.x2){ it.st = "dead"; } } | |
| } | |
| items = items.filter(it => it.st !== "dead"); | |
| // 堆:每层最多显示 140 个,多的从底下消掉(仍按规则判) | |
| // 推荐流里太挤就去掉旧的 | |
| // 推荐流传送带:上面每样东西的比例 = 当天推荐池里的真实比例 | |
| for (const b of belt) b.x += dt * 150 * Math.max(1, speed); | |
| belt = belt.filter(b => b.x < L.feed.x2 - 6); | |
| if (!belt.length || belt[belt.length-1].x > L.feed.x1 + 30){ | |
| if (!belt.length){ for (let x = L.feed.x2 - 20; x > L.feed.x1; x -= 30) belt.unshift({ x, type: sampleFeed() }); } | |
| belt.push({ x: L.feed.x1 + 4, type: sampleFeed() }); | |
| } | |
| // 走的人 | |
| for (const w of walkers){ w.t += dt; w.x -= dt*80; } | |
| walkers = walkers.filter(w => w.x > -60); | |
| // 用户 | |
| for (const d of dots){ | |
| if (d.leaving){ d.x += dt * 110; continue; } | |
| d.x += (d.hx - d.x) * Math.min(1, dt*2.2); d.y += (d.hy - d.y) * Math.min(1, dt*2.2); | |
| } | |
| dots = dots.filter(d => d.x < W + 40); | |
| // 反应 | |
| if (today){ const live = dots.filter(d => !d.leaving); const rate = live.length * 0.06 * dt * (REDUCED ? 0.3 : 1); | |
| let n = Math.floor(rate); if (Math.random() < rate - n) n++; | |
| for (let i = 0; i < n && live.length; i++){ const d = live[Math.floor(Math.random()*live.length)]; const r = Math.random(); const m = mixSeen(); | |
| const kind = r < m.cake ? "heart" : r < m.cake + m.poop ? "cloud" : null; if (kind) reacts.push({ kind, x:d.x, y:d.y-14, t:0 }); } } | |
| for (const r of reacts){ r.t += dt; r.y -= dt*22; } | |
| reacts = reacts.filter(r => r.t < 1.4); | |
| // 金币 | |
| if (today){ | |
| const base = startStats.revenue; | |
| spawnCoins("ad", today.revenue / base * 5 * dt); | |
| spawnCoins("share", today.pool / base * 5 * dt * 2.2); | |
| spawnCoins("cost", today.cost / base * 5 * dt * 2.2); | |
| } | |
| for (const c of coins){ c.t += dt; } | |
| coins = coins.filter(c => c.t < c.dur); | |
| // 字幕 | |
| capT += dt; | |
| if (caps.length && (!capShown || capT > 1.4 || capShown.key === "intro" || capShown.key === "ready")){ capShown = caps.shift(); capT = 0; } | |
| } | |
| const binCount = {}; let pileCount = [0,0,0]; | |
| function mixSeen(){ const f = today.fm, m = today.made, nt = m.cake + m.pop + m.poop || 1; const ts = P.TRIAL_SHARE; | |
| return { cake:(1-ts)*f.cake + ts*m.cake/nt, poop:(1-ts)*f.poop + ts*m.poop/nt }; } | |
| function placeOnPile(it, tier){ | |
| const s = L.sieve[tier]; let idx = pileCount[tier]; | |
| let row = 0, perRow = 15; while (idx >= perRow && row < 9){ idx -= perRow; row++; perRow = Math.max(3, 15 - row*1.5) | 0; } | |
| if (row >= 9){ it.tier = tier; it.t = 0; judge(it); return; } // 堆满了:不再往上堆,直接筛 | |
| const span = L.bowlW * 0.84 * perRow / 15; | |
| it.tier = tier; it.x = s.x - span/2 + (idx + 0.5) * (span/perRow) + Math.random()*3 - 1.5; it.y = s.y - 4 - row*7 + Math.random()*2; pileCount[tier]++; | |
| } | |
| function judge(it, silent){ | |
| const n = today ? today.sv.tiers[it.tier].n : P.TIERS[it.tier]; | |
| const pass = Math.random() < S.passRate(P.COMPLETION[it.type], n); | |
| if (silent){ it.st = "dead"; return; } | |
| if (pass){ | |
| if (it.tier < 2){ it.st = "drop"; it.vy = 40; it.vx = 0; it.next = it.tier + 1; it.x = L.sieve[it.tier].x - 50 + Math.random()*100; it.y = L.sieve[it.tier].y + 26; } | |
| else { it.st = "chute"; it.t = 0; it.sx = L.sieve[2].x + L.bowlW/2; it.sy = L.sieve[2].y + 2; } | |
| } else { it.st = "drop"; it.vy = 10; it.vx = (Math.random() < 0.5 ? -1 : 1) * (10 + Math.random()*30); it.next = null; it.rej = true; it.x = L.sieve[it.tier].x + (it.vx < 0 ? -1 : 1) * (L.bowlW*0.5 - 8); it.y = L.sieve[it.tier].y + 4; } | |
| } | |
| function spawnCoins(kind, expected){ | |
| let n = Math.floor(expected); if (Math.random() < expected - n) n++; | |
| for (let i = 0; i < n; i++) coins.push({ kind, t:0, dur: kind === "ad" ? 2.2 : kind === "share" ? 2.6 : 1.3, j:Math.random()*8-4 }); | |
| } | |
| // 金币路径 | |
| const PATH = { | |
| ad:[[L.adv.x-40, L.adv.y-10],[L.vault.x+70, L.vault.y-10]], | |
| share:[[L.vault.x-70, L.vault.y-10],[452, L.vault.y-10],[452, 650]], | |
| cost:[[L.vault.x+40, L.vault.y+45],[L.server.x-50, L.server.y+30]], | |
| }; | |
| function along(path, u){ | |
| let len = 0; const seg = []; for (let i = 1; i < path.length; i++){ const l = Math.hypot(path[i][0]-path[i-1][0], path[i][1]-path[i-1][1]); seg.push(l); len += l; } | |
| let d = u * len; for (let i = 0; i < seg.length; i++){ if (d <= seg[i]){ const v = d/seg[i]; return [path[i][0] + (path[i+1][0]-path[i][0])*v, path[i][1] + (path[i+1][1]-path[i][1])*v]; } d -= seg[i]; } | |
| return path[path.length-1]; | |
| } | |
| // ---------- 画 ---------- | |
| function drawPaper(){ | |
| ctx.fillStyle = C.paper; ctx.fillRect(0,0,W,H); | |
| ctx.strokeStyle = C.grid; ctx.lineWidth = 1; | |
| ctx.beginPath(); for (let x = 0; x <= W; x += 25){ ctx.moveTo(x,0); ctx.lineTo(x,H); } for (let y = 0; y <= H; y += 25){ ctx.moveTo(0,y); ctx.lineTo(W,y); } ctx.stroke(); | |
| } | |
| function sparkline(x,y,w,h,key,base){ | |
| if (hist.length < 2) return; | |
| const vals = hist.map(o => o[key]); const max = Math.max(base*1.15, ...vals); | |
| ctx.beginPath(); for (let i = 0; i < 366; i++){ if (i >= vals.length) break; const px = x + i/365*w, py = y + h - vals[i]/max*h; if (i === 0) ctx.moveTo(px,py); else ctx.lineTo(px,py); } | |
| ctx.strokeStyle = C.accent; ctx.lineWidth = 2; ctx.stroke(); | |
| ctx.setLineDash([3,4]); ctx.strokeStyle = C.soft; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y + h - base/max*h); ctx.lineTo(x+w, y + h - base/max*h); ctx.stroke(); ctx.setLineDash([]); | |
| } | |
| function drawTop(){ | |
| ctx.fillStyle = "#ffffffcc"; ctx.fillRect(0,0,W,112); line(0,112,W,112,3,2.5); | |
| text(tr("title"), 30, 52, lang === "zh" ? 40 : 30, C.ink, "left", 800); | |
| text(tr("sub"), 32, 86, 18, C.soft); | |
| // 倍数和天数 | |
| const k = today ? today.k : 1; | |
| text(tr("ai"), 500, 44, 18, C.soft); | |
| text("×" + fmtK(started ? k : kSel), 500, 92, 46, k > 1.05 ? C.bad : C.ink, "left", 800); | |
| text(tr("day", { d: world.day }), 690, 92, 22, C.ink, "left", 600); | |
| if (!started){ text(lang === "zh" ? "(即将到来)" : "(coming)", 690, 60, 15, C.soft); } | |
| // 四个表 | |
| const t = today; | |
| const ms = [ | |
| { v: fmtInt(t.users), key:"users", base:P.U0, bad: t.users < 0.85*P.U0 }, | |
| { v: (lang==="zh" ? "" : "¥") + fmtInt(t.revenue) + (lang==="zh" ? " 元" : ""), key:"revenue", base:startStats.revenue, bad: t.revenue < 0.85*startStats.revenue }, | |
| { v: (lang==="zh" ? "" : "¥") + fmtInt(t.cost) + (lang==="zh" ? " 元" : ""), key:"cost", base:startStats.cost, bad: t.cost > 1.5*startStats.cost }, | |
| { v: Math.round(t.cakeShareTop*100) + "%", key:"cake", base:1, bad: t.cakeShareTop < 0.5 }, | |
| ]; | |
| for (let i = 0; i < 4; i++){ | |
| const x = 840 + i*188, y = 12, w = 176; | |
| rrect(x, y, w, 90, 100+i*9, "#fff", C.ink, 1.6); | |
| text(tr("m")[i], x+12, y+24, 15, C.soft); | |
| text(ms[i].v, x+12, y+58, 27, ms[i].bad ? C.bad : C.ink, "left", 800); | |
| sparkline(x+12, y+64, w-24, 20, ms[i].key, ms[i].base); | |
| } | |
| } | |
| function drawCaption(){ | |
| if (!capShown) return; | |
| const s = tr("cap." + capShown.key, capShown.vars); | |
| const size = 24, w = textW(s, size, 700) + 44, x = W/2 - w/2 - 60, y = 124; | |
| ctx.save(); ctx.globalAlpha = Math.min(1, capT*4 + (capShown.key==="intro"?1:0)); | |
| rrect(x, y, w, 42, 999, "#fff8d6", C.ink, 1.8); | |
| text(s, x + w/2, y + 30, size, C.ink, "center", 700); ctx.restore(); | |
| } | |
| function drawStage(){ | |
| const st = L.stage; | |
| // 台子 | |
| rrect(st.x+6, 620, st.w-20, 22, 11, "#e8dcc2", C.ink, 2.2); | |
| line(st.x+20, 642, st.x+20, 660, 12, 2.2); line(st.x+st.w-30, 642, st.x+st.w-30, 660, 13, 2.2); | |
| text(tr("stage"), st.x+14, 168, 20, C.ink, "left", 700); | |
| text(tr("stageNote"), st.x+14 + textW(tr("stage"), 20, 700) + 10, 168, 13, C.soft); | |
| for (let i = 0; i < slots.length; i++){ | |
| const s = slots[i]; if (!s.type) continue; const p = L.slots[i]; | |
| const f = s.f || 0.1; const blur = f > 3.2 ? 1 : 0; const push = blur ? 0 : Math.max(0, 1 - Math.abs(s.phase - 0.12) * 6); | |
| ctx.save(); const sc = 0.4 + 0.6 * easeOut(s.pop); ctx.translate(p.x, p.y); ctx.scale(sc, sc); | |
| drawPerson(0, 0, s.type, { seed: s.seed, push, blur, mood: 1, blink: (performance.now()/400 + i) % 2 < 1, spark: s.spark > 0 }); | |
| ctx.restore(); | |
| } | |
| for (const w of walkers){ drawPerson(w.x, w.y, w.type, { seed: w.seed, pack: true, flip: true, walk: w.t*9, mood: -1, alpha: Math.max(0, Math.min(1, (w.x+40)/120)) }); } | |
| // 人数 | |
| const c = today.count; | |
| const legend = [["cake", tr("cake"), c.cake], ["pop", tr("pop"), c.pop], ["poop", tr("bot"), c.poop]]; | |
| // 竖排,一行一种,图标放大到看得清 | |
| legend.forEach(([type, name, n], i) => { | |
| const y = 706 + i * 68; | |
| drawItem(type, st.x + 46, y, 50); | |
| const col = type === "poop" && n > 0 ? C.bad : C.ink; | |
| text(name, st.x + 92, y + 10, 26, col, "left", 700); | |
| text(fmtInt(n), st.x + 92 + textW(name, 26, 700) + 14, y + 10, 30, col, "left", 800); | |
| }); | |
| } | |
| function easeOut(u){ return 1 - Math.pow(1-u, 3); } | |
| function drawSieves(){ | |
| for (let i = 0; i < 3; i++){ | |
| const s = L.sieve[i], bw = L.bowlW; | |
| // 碗 | |
| ctx.beginPath(); ctx.moveTo(s.x - bw/2, s.y); ctx.quadraticCurveTo(s.x, s.y + 64, s.x + bw/2, s.y); ctx.strokeStyle = C.ink; ctx.lineWidth = 2.4; ctx.stroke(); | |
| ctx.save(); ctx.beginPath(); ctx.moveTo(s.x - bw/2, s.y); ctx.quadraticCurveTo(s.x, s.y + 64, s.x + bw/2, s.y); ctx.closePath(); ctx.clip(); | |
| ctx.strokeStyle = "#9aa7b8"; ctx.lineWidth = 1; ctx.beginPath(); for (let x = -bw/2; x < bw/2; x += 10){ ctx.moveTo(s.x + x, s.y); ctx.lineTo(s.x + x + 30, s.y + 40); ctx.moveTo(s.x + x + 30, s.y); ctx.lineTo(s.x + x, s.y + 40); } ctx.stroke(); ctx.restore(); | |
| line(s.x - bw/2 - 10, s.y, s.x + bw/2 + 10, s.y, 300+i, 2.6); | |
| // 试吃的人 | |
| const n = today.sv.tiers[i].n; const tasters = Math.max(1, Math.round(5 * today.users / P.U0)); | |
| for (let j = 0; j < tasters; j++){ const tx = s.x + bw/2 + 34 + (j%3)*24, ty = s.y - 12 + Math.floor(j/3)*30; const bob = Math.sin(performance.now()/180 + j*1.7) * 2; | |
| circ(tx, ty + bob, 8, "#fff", C.ink, 1.8); circ(tx-3, ty-2+bob, 1.2, C.ink, false); circ(tx+3, ty-2+bob, 1.2, C.ink, false); line(tx-3, ty+3+bob, tx+3, ty+3+bob, j, 1.2, C.ink, 0.2); } | |
| const lab = tr("tier")[i]; | |
| } | |
| // 没被推荐 | |
| ctx.beginPath(); ctx.ellipse(L.bin.x, L.bin.y + 6, 120, 14, 0, 0, Math.PI*2); ctx.fillStyle = "#eceae3"; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.6; ctx.stroke(); | |
| text(tr("rejected"), L.bin.x, L.bin.y + 42, 14, C.soft, "center"); | |
| // 滑道 | |
| line(L.sieve[2].x + L.bowlW/2, L.sieve[2].y + 12, L.feed.x1 - 6, L.feed.y + 14, 410, 2.2, C.ink, 0.8); | |
| } | |
| function drawTierLabels(){ | |
| for (let i = 0; i < 3; i++){ const s = L.sieve[i], n = today.sv.tiers[i].n, lab = tr("tier")[i]; | |
| const nTxt = n >= 10 ? fmtInt(n) : n >= 0.1 ? (Math.round(n*10)/10).toString() : (lang === "zh" ? "不到 0.1" : "<0.1"); | |
| const lt = lab + " " + tr("perItem", { n: nTxt }); const lw2 = textW(lt, 14, 700) + 20; | |
| rrect(s.x - lw2/2, s.y + 46, lw2, 26, 320+i, "#fffffff2", C.ink, 1.2); | |
| text(lab, s.x - lw2/2 + 10, s.y + 64, 14, C.ink, "left", 800); | |
| text(tr("perItem", { n: nTxt }), s.x - lw2/2 + 10 + textW(lab + " ", 14, 800), s.y + 64, 14, n < P.TIERS[i] * 0.3 ? C.bad : C.soft, "left", 700); } | |
| } | |
| function sampleFeed(){ const f = today ? today.fm : { cake:1, pop:0, poop:0 }; const r = Math.random(); return r < f.cake ? "cake" : r < f.cake + f.pop ? "pop" : "poop"; } | |
| function drawItems(){ | |
| for (const it of items){ const sz = it.st === "fly" ? 13 : 15; const a = it.rej ? Math.max(0.15, 1 - (it.y - L.sieve[it.tier].y) / 160) : 1; drawItem(it.type, it.x, it.y, sz, a); } | |
| for (const b of belt) drawItem(b.type, b.x, L.feed.y - 2, 22); | |
| } | |
| function drawPlaza(){ | |
| // 推荐流 | |
| text(tr("feed"), L.feed.x1, L.feed.y + 46, 16, C.ink, "left", 700); | |
| line(L.feed.x1 - 8, L.feed.y + 16, L.feed.x2 + 6, L.feed.y + 16, 500, 2.4); | |
| for (let x = L.feed.x1; x < L.feed.x2; x += 28){ circ(x + ((performance.now()/1000*70) % 28), L.feed.y + 21, 3.5, "#fff", C.ink, 1.4); } | |
| text(tr("plaza"), L.plaza.x + 10, L.plaza.y + 18, 18, C.ink, "left", 700); | |
| text(tr("plazaNote"), L.plaza.x + 10 + textW(tr("plaza"), 18, 700) + 10, L.plaza.y + 18, 13, C.soft); | |
| const sat = today ? today.sat : 0.9; | |
| for (const d of dots){ | |
| const m = sat + d.off; const mood = m > 0.55 ? 1 : m > -0.1 ? 0 : -1; | |
| ctx.save(); ctx.translate(d.x, d.y); const fill = mood > 0 ? "#ffeef0" : mood < 0 ? "#e9e3dc" : "#ffffff"; | |
| circ(0,0,9,fill,C.ink,1.6); circ(-3,-2,1.3,C.ink,false); circ(3,-2,1.3,C.ink,false); | |
| ctx.beginPath(); ctx.strokeStyle = C.ink; ctx.lineWidth = 1.3; if (mood > 0) ctx.arc(0,1,3.5,0.15*Math.PI,0.85*Math.PI); else if (mood === 0){ ctx.moveTo(-3,4); ctx.lineTo(3,4); } else ctx.arc(0,7,3.5,1.15*Math.PI,1.85*Math.PI); ctx.stroke(); | |
| ctx.restore(); | |
| } | |
| for (const r of reacts){ ctx.save(); ctx.globalAlpha = Math.max(0, 1 - r.t/1.4); | |
| if (r.kind === "heart"){ ctx.fillStyle = C.cake; ctx.beginPath(); const x = r.x, y = r.y; ctx.moveTo(x, y+4); ctx.bezierCurveTo(x-8, y-2, x-4, y-9, x, y-4); ctx.bezierCurveTo(x+4, y-9, x+8, y-2, x, y+4); ctx.fill(); } | |
| else { ctx.fillStyle = "#7c8796"; circ(r.x-4, r.y, 4, "#7c8796", false); circ(r.x+3, r.y-2, 5, "#7c8796", false); circ(r.x+8, r.y+1, 3.5, "#7c8796", false); } | |
| ctx.restore(); } | |
| } | |
| function drawMoney(){ | |
| const t = today; | |
| // 注意力:用户 → 广告商 | |
| ctx.setLineDash([6,6]); line(1420, 628, L.adv.x - 6, L.adv.y - 70, 600, 1.8, C.accent, 0.5); ctx.setLineDash([]); | |
| text(tr("attention"), 1455, 672, 14, C.accent, "right", 600); | |
| // 管道 | |
| const pipe = (path, w, seed) => { ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = C.ink; ctx.lineWidth = w + 4; ctx.beginPath(); path.forEach(([x,y],i) => i ? ctx.lineTo(x,y) : ctx.moveTo(x,y)); ctx.stroke(); ctx.strokeStyle = "#fff"; ctx.lineWidth = w; ctx.stroke(); ctx.lineCap = "butt"; }; | |
| const base = startStats.revenue; | |
| pipe(PATH.ad, 6 + 14 * Math.min(1.5, t.revenue/base), 1); | |
| pipe(PATH.share, 3 + 6 * Math.min(1.5, t.pool/(base*P.CREATOR_SHARE)), 2); | |
| pipe(PATH.cost, 3 + 5 * Math.min(4, t.cost/startStats.cost), 3); | |
| for (const c of coins){ const u = c.t / c.dur; const [x,y] = along(PATH[c.kind], u); circ(x, y + (c.kind === "share" ? 0 : c.j*0.3), 6, C.coin, C.ink, 1.3); circ(x-1.5, y-1.5, 2, C.coinHi, false); } | |
| // 广告商 | |
| drawPerson(L.adv.x, L.adv.y + 30, "pop", { seed: 901, mood: t.revenue > 0.85*base ? 1 : 0, push: 0 }); | |
| ctx.fillStyle = C.accent; ctx.beginPath(); ctx.moveTo(L.adv.x, L.adv.y + 0); ctx.lineTo(L.adv.x - 3, L.adv.y + 14); ctx.lineTo(L.adv.x + 3, L.adv.y + 14); ctx.fill(); | |
| rrect(L.adv.x + 14, L.adv.y + 2, 24, 18, 902, "#8f6b3e", C.ink, 1.6); | |
| text(tr("adv"), L.adv.x, L.adv.y + 72, 16, C.ink, "center", 700); | |
| text(tr("adIn", { v: fmtInt(t.revenue) }), (PATH.ad[0][0] + PATH.ad[1][0]) / 2, L.adv.y - 34, 15, C.ink, "center", 600); | |
| // 金库 | |
| rrect(L.vault.x - 60, L.vault.y - 50, 120, 96, 910, "#e7ecf3", C.ink, 2.4); | |
| circ(L.vault.x, L.vault.y - 2, 18, "#fff", C.ink, 2); line(L.vault.x, L.vault.y - 2, L.vault.x + 12, L.vault.y - 10, 911, 2); | |
| text(tr("vault"), L.vault.x, L.vault.y - 60, 16, C.ink, "center", 700); | |
| const net = t.revenue * (1 - P.CREATOR_SHARE) - t.cost; | |
| text(net >= 0 ? tr("keep", { v: fmtInt(net) }) : tr("lose", { v: fmtInt(-net) }), L.vault.x, L.vault.y + 72, 15, net >= 0 ? C.good : C.bad, "center", 700); | |
| // 服务器 | |
| const racks = Math.min(6, Math.max(1, Math.round(t.cost / startStats.cost))); | |
| for (let i = 0; i < racks; i++){ rrect(L.server.x - 40 + (i%3)*30, L.server.y - (Math.floor(i/3))*30 - 4, 26, 28, 920+i, "#dfe4ea", C.ink, 1.6); circ(L.server.x - 33 + (i%3)*30, L.server.y + 4 - Math.floor(i/3)*30, 2, C.good, false); } | |
| text(tr("server"), L.server.x + 60, L.server.y + 14, 14, C.soft, "left"); | |
| text((lang==="zh" ? "" : "¥") + fmtInt(t.cost) + (lang==="zh" ? " 元/天" : "/day"), L.server.x + 60, L.server.y + 34, 15, t.cost > 1.5*startStats.cost ? C.bad : C.ink, "left", 700); | |
| // 分给创作者 | |
| text(tr("toCreators", { v: fmtInt(t.pool) }), 480, L.vault.y - 26, 15, C.ink, "left", 600); | |
| } | |
| function drawEnd(){ | |
| if (!ended || !endStats) return; | |
| const fail = endStats.users < 0.75*P.U0 || endStats.cake < 0.5; | |
| const head = fail ? tr("endFail", { k: fmtK(kSel) }) : tr("endOk", { k: fmtK(kSel) }); | |
| ctx.save(); ctx.fillStyle = "#fbfbf7e6"; ctx.fillRect(0, 112, W, H-112); | |
| const x = 330, y = 210, w = 940, h = 470; | |
| rrect(x, y, w, h, 1001, "#fff", C.ink, 2.6); | |
| text(tr("endHead"), x + 40, y + 58, 20, C.soft, "left", 700); | |
| const hs = textW(head, 34, 800) > w - 80 ? 26 : 34; | |
| text(head, x + 40, y + 108, hs, C.ink, "left", 800); | |
| const rows = [ | |
| [tr("m")[0], fmtInt(startStats.users), fmtInt(endStats.users)], | |
| [tr("m")[1], fmtInt(startStats.revenue), fmtInt(endStats.revenue)], | |
| [tr("m")[2], fmtInt(startStats.cost), fmtInt(endStats.cost)], | |
| [tr("m")[3], Math.round(startStats.cake*100) + "%", Math.round(endStats.cake*100) + "%"], | |
| ]; | |
| text(tr("from"), x + 520, y + 168, 16, C.soft, "right", 700); text(tr("to"), x + 760, y + 168, 16, C.soft, "right", 700); | |
| rows.forEach((r,i) => { const yy = y + 214 + i*52; line(x + 40, yy + 16, x + w - 40, yy + 16, 1100+i, 1, C.grid, 0.4); | |
| text(r[0], x + 40, yy, 20, C.ink, "left", 600); text(r[1], x + 520, yy, 22, C.ink, "right", 700); text("→", x + 600, yy, 22, C.soft, "center"); text(r[2], x + 760, yy, 22, C.ink, "right", 800); }); | |
| text(tr("again"), x + 40, y + h - 30, 15, C.soft, "left"); | |
| ctx.restore(); | |
| } | |
| function render(){ | |
| ctx.setTransform(DPR,0,0,DPR,0,0); | |
| drawPaper(); drawStage(); drawSieves(); drawPlaza(); drawMoney(); drawItems(); drawTierLabels(); drawTop(); drawCaption(); drawEnd(); | |
| } | |
| // ---------- 循环 ---------- | |
| let last = performance.now(); | |
| function frame(now){ | |
| const dt = Math.min(0.05, (now - last) / 1000); last = now; | |
| update(dt); render(); requestAnimationFrame(frame); | |
| } | |
| // ---------- 控件 ---------- | |
| const btnPlay = document.getElementById("play"), btnReset = document.getElementById("reset"), kIn = document.getElementById("k"), kOut = document.getElementById("kOut"), btnLang = document.getElementById("lang"); | |
| const presets = [1, 3, 10, 30, 100]; const presetBox = document.getElementById("presets"); | |
| presets.forEach(v => { const b = document.createElement("button"); b.type = "button"; b.textContent = "×" + v; b.addEventListener("click", () => { setK(v); }); presetBox.appendChild(b); }); | |
| function niceK(v){ if (v < 10) return Math.round(v*10)/10; return Math.round(v); } | |
| function setK(v){ kSel = niceK(v); kIn.value = Math.log10(kSel); kOut.textContent = "×" + fmtK(kSel); world.kTarget = kSel; if (!started) reset(); else if (world.day >= world.aiDay) {/* 运行中改倍数:立刻生效 */} } | |
| kIn.addEventListener("input", () => setK(Math.pow(10, +kIn.value))); | |
| btnPlay.addEventListener("click", () => { | |
| if (ended){ reset(); } | |
| if (!started){ started = true; running = true; capShown = { key:"intro" }; capT = 0; } | |
| else running = !running; | |
| setPlayLabel(); | |
| }); | |
| btnReset.addEventListener("click", () => { reset(); }); | |
| document.querySelectorAll("[data-speed]").forEach(b => b.addEventListener("click", () => { speed = +b.dataset.speed; document.querySelectorAll("[data-speed]").forEach(o => o.setAttribute("aria-pressed", o === b ? "true" : "false")); })); | |
| btnLang.addEventListener("click", () => setLang(lang === "zh" ? "en" : "zh")); | |
| function setPlayLabel(){ btnPlay.textContent = ended ? tr("play") : !started ? tr("play") : running ? tr("pause") : tr("resume"); } | |
| function setLang(l){ lang = l; document.documentElement.lang = l === "zh" ? "zh-CN" : "en"; | |
| btnLang.textContent = tr("lang"); btnReset.textContent = tr("reset"); document.getElementById("kLab").textContent = tr("kLab"); document.getElementById("spLab").textContent = tr("spLab"); | |
| const sp = document.querySelectorAll("[data-speed]"); sp[0].textContent = tr("slow"); sp[1].textContent = tr("normal"); sp[2].textContent = tr("fast"); | |
| document.getElementById("note").textContent = tr("note"); cv.setAttribute("aria-label", l === "zh" ? "推荐池模拟画面" : "Recommendation pool simulation"); setPlayLabel(); } | |
| // 截图用:快进到某一天,并让画面上的东西先流动一会儿 | |
| window.demo = { | |
| jump(k, day, warmSec){ kSel = k; kIn.value = Math.log10(k); kOut.textContent = "×" + fmtK(k); reset(); started = true; running = false; | |
| while (world.day < day){ today = S.step(world); pushHist(today); } peakBots = 0; layoutSlots(true); layoutDots(true); caps = []; capShown = null; ended = false; | |
| const steps = Math.round((warmSec || 4) / 0.02); for (let i = 0; i < steps; i++) update(0.02); }, | |
| setLang, get today(){ return today; }, get world(){ return world; }, | |
| finishAt(k){ this.jump(k, 365, 3); finish(); }, | |
| }; | |
| reset(); setK(kSel); | |
| setLang("en"); | |
| capShown = { key:"ready" }; | |
| requestAnimationFrame(frame); | |
| })(); | |
| </script> | |