diff --git a/Dockerfile b/Dockerfile index 5572499..e901ef3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM nginx:1.27-alpine COPY web /usr/share/nginx/html +COPY assets /usr/share/nginx/html/assets COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 8080 HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD wget -q -O- http://127.0.0.1:8080/health || exit 1 diff --git a/assets/test-covers/rice.jpg b/assets/test-covers/rice.jpg new file mode 100644 index 0000000..d6d2db3 Binary files /dev/null and b/assets/test-covers/rice.jpg differ diff --git a/package.json b/package.json index 4b303b5..862c296 100644 --- a/package.json +++ b/package.json @@ -1,2 +1 @@ -{"name":"rice-pk-xhs","version":"0.1.0","private":true,"scripts":{"check":"node scripts/check.mjs"}} - +{"name":"rice-pk-xhs","version":"0.1.0","private":true,"scripts":{"check":"node scripts/check.mjs","test":"node --test test/*.test.cjs"}} diff --git a/pages/index/index.xhsml b/pages/index/index.xhsml index 425eb88..3e44157 100644 --- a/pages/index/index.xhsml +++ b/pages/index/index.xhsml @@ -3,7 +3,7 @@ {{error}} - ONLY RICE · 48 强🍚 + ONLY RICE · 48 强 本命饭终极 PK蛋炒饭、煲仔饭、咖喱饭……每轮二选一,看看谁能成为你唯一的那碗饭。 diff --git a/pages/result/result.css b/pages/result/result.css index 4c7c1c2..bb44c96 100644 --- a/pages/result/result.css +++ b/pages/result/result.css @@ -1 +1,24 @@ -.kicker{text-align:center;color:#ff6b35;font-size:22rpx;letter-spacing:6rpx;font-weight:900}.headline{margin:12rpx 0 28rpx;text-align:center;font-size:48rpx;font-weight:900}.champion{position:relative;overflow:hidden;padding:54rpx 32rpx;text-align:center;border-radius:38rpx;background:#25211f;color:#fff}.crown{position:absolute;right:22rpx;top:0;color:#ffe568;font-size:100rpx;transform:rotate(13deg)}.muted-light{opacity:.6;font-size:23rpx}.winner{margin-top:16rpx;font-size:72rpx;font-weight:900}.runner{margin-top:20rpx;color:#ded4ce;font-size:23rpx}.poster-head{display:flex;justify-content:space-between;margin:46rpx 4rpx 20rpx;font-size:30rpx;font-weight:900}.poster{width:100%;height:925rpx;border-radius:30rpx;box-shadow:0 16rpx 40rpx rgba(50,30,20,.12)}.themes{display:flex;margin-top:24rpx;overflow-x:auto}.themes button{flex:none;margin-right:12rpx;padding:0 24rpx;height:66rpx;line-height:66rpx;border-radius:99rpx;background:#fff;color:#635951;font-size:22rpx}.themes .active{background:#25211f;color:#fff}.sticker{display:flex;justify-content:space-between;margin-top:24rpx;padding:24rpx;border-radius:22rpx;background:#fff}.action{margin-top:20rpx;font-size:28rpx} +.editor { min-height: 100vh; padding: 20rpx 22rpx 40rpx; background: #151515; color: #fff; } +.editor-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18rpx; } +.editor-head text { font-size: 31rpx; font-weight: 900; } +.editor-head view view { margin-top: 4rpx; color: #888; font-size: 19rpx; } +.export { margin: 0; padding: 0 30rpx; min-width: 0; height: 64rpx; line-height: 64rpx; border-radius: 99rpx; background: #ff3156; color: #fff; font-size: 23rpx; font-weight: 800; } +.poster { width: 100%; height: 880rpx; background: #242424; } +.strip, .media-strip, .tool-strip { white-space: nowrap; } +.strip-row, .media-row, .tool-row { display: flex; align-items: center; } +.strip { margin-top: 18rpx; } +.chip { flex: none; margin-right: 10rpx; padding: 0 22rpx; min-width: 0; height: 54rpx; line-height: 54rpx; border-radius: 8rpx; background: #292929; color: #aaa; font-size: 20rpx; } +.chip.active { background: #fff; color: #111; } +.media-strip { margin-top: 22rpx; } +.media-thumb, .add-media { flex: none; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 112rpx; height: 112rpx; margin-right: 12rpx; border: 2rpx solid #444; border-radius: 10rpx; background: #252525; color: #fff; font-size: 20rpx; } +.bracket-thumb { border-color: #fff; background: #f3eadf; color: #111; font-weight: 800; } +.add-media text { margin-top: 5rpx; color: #999; font-size: 16rpx; } +.tool-strip { margin-top: 25rpx; } +.tool { flex: none; width: 104rpx; text-align: center; color: #888; font-size: 19rpx; } +.tool.active { color: #fff; } +.tool-icon { display: grid; place-items: center; width: 50rpx; height: 50rpx; margin: 0 auto 9rpx; border: 3rpx solid currentColor; border-radius: 12rpx; font-size: 18rpx; } +.tool-panel { display: flex; margin-top: 22rpx; padding: 18rpx; overflow: hidden; background: #222; } +.theme-chip { flex: 1; margin: 0 5rpx; padding: 0; height: 58rpx; line-height: 58rpx; border-radius: 6rpx; background: #333; color: #aaa; font-size: 19rpx; } +.theme-chip.active { background: #fff; color: #111; } +.setting-row { display: flex; width: 100%; justify-content: space-between; font-size: 22rpx; } +.hint { color: #777; font-size: 20rpx; } diff --git a/pages/result/result.js b/pages/result/result.js index ae04067..a7c96fb 100644 --- a/pages/result/result.js +++ b/pages/result/result.js @@ -1,20 +1,162 @@ -var api=require('../../utils/api'); -var THEMES={celebration:{bg:'#ff6b35',ink:'#fffaf4',card:'#25211f',accent:'#ffe568'},editorial:{bg:'#f3eadf',ink:'#201d1b',card:'#fff',accent:'#285ee8'},sticker:{bg:'#c9f06b',ink:'#1e2810',card:'#ff72a7',accent:'#fff'}}; +var api = require('../../utils/api'); +var posterEditor = require('../../vendor/poster-editor.cjs'); + +var THEMES = { + celebration: { bg: '#ff6b35', ink: '#fffaf4', card: '#25211f', accent: '#ffe568', label: '庆祝派对' }, + editorial: { bg: '#f3eadf', ink: '#201d1b', card: '#ffffff', accent: '#285ee8', label: '杂志封面' }, + sticker: { bg: '#c9f06b', ink: '#1e2810', card: '#ff72a7', accent: '#ffffff', label: '贴纸手账' } +}; + Page({ - data:{result:null,template:'celebration',sticker:true,saving:false}, - onLoad:function(options){this.runId=options.id;var cached=xhs.getStorageSync('rice_last_result');if(cached&&cached.id===options.id)this.setData({result:cached});else this.load();}, - onReady:function(){if(this.data.result)this.draw();}, - load:function(){var self=this;api.ensureLogin().then(function(){return api.request('/api/v1/me/results/'+self.runId);}).then(function(result){self.setData({result:result});self.draw();}).catch(function(error){xhs.showModal({title:'读取失败',content:error.message||'请重试',showCancel:false});});}, - pick:function(event){this.setData({template:event.currentTarget.dataset.name});this.draw();}, - toggle:function(){this.setData({sticker:!this.data.sticker});this.draw();}, - draw:function(){ - var r=this.data.result;if(!r)return; - var t=THEMES[this.data.template],isCelebration=this.data.template==='celebration',showSticker=this.data.sticker; - xhs.createSelectorQuery().select('.poster').boundingClientRect(function(rect){ - var ctx=xhs.createCanvasContext('poster');if(rect)ctx.scale(rect.width/750,rect.height/1000); - ctx.setFillStyle(t.bg);ctx.fillRect(0,0,750,1000);ctx.setFillStyle(t.ink);ctx.setFontSize(24);ctx.fillText('RICE PK / MY FINAL PICK',54,72);ctx.setFontSize(58);ctx.fillText('我的本命饭',54,156);ctx.setFillStyle(t.card);ctx.fillRect(46,205,658,440);ctx.setFillStyle(isCelebration?'#fffaf4':t.ink);ctx.setFontSize(26);ctx.fillText('48 种饭 · 一路二选一',84,270);ctx.setFontSize(r.champion_name.length>7?62:82);ctx.fillText(r.champion_name,84,405);ctx.setFillStyle(t.accent);ctx.fillRect(84,440,Math.min(500,r.champion_name.length*80),18);ctx.setFillStyle(isCelebration?'#fffaf4':t.ink);ctx.setFontSize(24);ctx.fillText('今天就吃它,不接受反驳。',84,525);ctx.setFillStyle(t.ink);ctx.setFontSize(25);ctx.fillText('我的 TOP 4',54,720);(r.ranking_names||[]).slice(0,4).forEach(function(name,index){ctx.setFontSize(26);ctx.fillText((index+1)+'. '+name,64,774+index*48);});if(showSticker){ctx.setFillStyle(t.accent);ctx.beginPath();ctx.arc(645,125,58,0,Math.PI*2);ctx.fill();ctx.setFillStyle(t.card);ctx.setFontSize(28);ctx.fillText('开饭!',607,134);ctx.setFontSize(52);ctx.fillText('✦',640,700);ctx.fillText('♡',550,900);}ctx.setFillStyle(t.ink);ctx.setFontSize(20);ctx.fillText('保存分享图,看看朋友会选哪一碗',54,956);ctx.draw(); - }).exec(); - }, - save:function(){var self=this;this.setData({saving:true});xhs.canvasToTempFilePath({canvasId:'poster',destWidth:750,destHeight:1000,success:function(file){xhs.saveImageToPhotosAlbum({filePath:file.tempFilePath,success:function(){xhs.showToast({title:'已保存到相册'});},fail:function(){xhs.showToast({title:'请检查相册权限',icon:'none'});},complete:function(){self.setData({saving:false});}});},fail:function(){self.setData({saving:false});xhs.showToast({title:'生成失败',icon:'none'});}});}, - replay:function(){xhs.redirectTo({url:'/pages/test/test'});} + data: { + result: null, + template: 'celebration', + themeList: [ + { name: 'celebration', label: '庆祝派对' }, + { name: 'editorial', label: '杂志封面' }, + { name: 'sticker', label: '贴纸手账' } + ], + sticker: true, + bracketLayout: 'symmetric', + bracketView: 'overview', + activeTool: 'filter', + backgroundPath: '', + tools: [ + { name: 'filter', label: '滤镜' }, { name: 'text', label: '文字' }, + { name: 'sticker', label: '贴纸' }, { name: 'crop', label: '裁剪' }, + { name: 'adjust', label: '调整' }, { name: 'layout', label: '布局' }, + { name: 'brush', label: '画笔' } + ], + saving: false + }, + onLoad: function (options) { + this.posterDocument = posterEditor.createDocument({ id: 'run-' + options.id, seed: options.id, width: 750, height: 1000, meta: { sourceType: 'pk-run', sourceId: options.id } }); + var cached = xhs.getStorageSync('rice_last_result'); + this.runId = options.id; + if (cached && cached.id === options.id) this.setData({ result: cached }); + else this.load(); + }, + onReady: function () { if (this.data.result) this.drawPoster(); }, + load: function () { + var self = this; + api.ensureLogin().then(function () { return api.request('/api/v1/me/results/' + self.runId); }).then(function (result) { + self.setData({ result: result }); self.drawPoster(); + }).catch(function (error) { xhs.showModal({ title: '读取失败', content: error.message || '请稍后重试', showCancel: false }); }); + }, + pickTemplate: function (event) { + this.setData({ template: event.currentTarget.dataset.name }); + this.drawPoster(); + }, + toggleSticker: function () { this.setData({ sticker: !this.data.sticker }); this.drawPoster(); }, + pickBracketLayout: function (event) { this.setData({ bracketLayout: event.currentTarget.dataset.layout }); this.drawPoster(); }, + pickBracketView: function (event) { this.setData({ bracketView: event.currentTarget.dataset.view }); this.drawPoster(); }, + pickTool: function (event) { this.setData({ activeTool: event.currentTarget.dataset.tool }); }, + chooseBackground: function () { + var self = this; + xhs.chooseImage({ + count: 1, + success: function (result) { + var path = result.tempFilePaths && result.tempFilePaths[0]; + if (path) { self.setData({ backgroundPath: path }); self.drawPoster(); } + } + }); + }, + drawPoster: function () { + var result = this.data.result; + if (!result) return; + var theme = THEMES[this.data.template]; + var isCelebration = this.data.template === 'celebration'; + var showSticker = this.data.sticker; + var layout = this.data.bracketLayout; + var view = this.data.bracketView; + var backgroundPath = this.data.backgroundPath; + xhs.createSelectorQuery().select('.poster').boundingClientRect(function (rect) { + var ctx = xhs.createCanvasContext('poster'); + if (rect) ctx.scale(rect.width / 750, rect.height / 1000); + ctx.setFillStyle(theme.bg); ctx.fillRect(0, 0, 750, 1000); + if (backgroundPath) { + ctx.drawImage(backgroundPath, 0, 0, 750, 1000); + ctx.setGlobalAlpha(.82); ctx.setFillStyle(theme.bg); ctx.fillRect(0, 0, 750, 1000); ctx.setGlobalAlpha(1); + } + ctx.setFillStyle(theme.ink); ctx.setFontSize(24); ctx.fillText('PK 研究所 / MY PICK', 54, 72); + ctx.setFontSize(58); ctx.fillText('我的冠军是', 54, 156); + ctx.setFillStyle(theme.card); ctx.fillRect(30, 190, 690, 610); + ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink); + ctx.setFontSize(22); ctx.fillText(result.test_title, 54, 235); + drawBracket(ctx, result, layout, view, theme, isCelebration); + if (showSticker) { + ctx.setFillStyle(theme.accent); ctx.beginPath(); ctx.arc(645, 125, 58, 0, Math.PI * 2); ctx.fill(); + ctx.setFillStyle(theme.card); ctx.setFontSize(24); ctx.fillText('WIN', 615, 134); + } + ctx.setFillStyle(theme.ink); ctx.setFontSize(20); ctx.fillText('长按保存,把答案分享给同频的人', 54, 956); + ctx.draw(); + }).exec(); + }, + savePoster: function () { + var self = this; + this.setData({ saving: true }); + xhs.canvasToTempFilePath({ + canvasId: 'poster', destWidth: 750, destHeight: 1000, + success: function (file) { + xhs.saveImageToPhotosAlbum({ + filePath: file.tempFilePath, + success: function () { xhs.showToast({ title: '已保存到相册' }); }, + fail: function () { xhs.showToast({ title: '保存失败,请检查相册权限', icon: 'none' }); }, + complete: function () { self.setData({ saving: false }); } + }); + }, + fail: function () { self.setData({ saving: false }); xhs.showToast({ title: '海报生成失败', icon: 'none' }); } + }); + }, + replay: function () { xhs.redirectTo({ url: '/pages/test/test?slug=' + this.data.result.test_slug }); } }); + +function drawBracket(ctx, result, layout, view, theme, isCelebration) { + var decisions = result.decisions || []; + var names = result.candidate_names || {}; + if (!decisions.length) { + ctx.setFillStyle(isCelebration ? '#fffaf4' : theme.ink); + ctx.setFontSize(72); + ctx.fillText(result.champion_name, 70, 480); + return; + } + var maxRound = decisions.reduce(function (max, item) { return Math.max(max, item.round); }, 1); + var minimumRound = view === 'final' ? maxRound : view === 'top8' ? Math.max(1, maxRound - 2) : view === 'top16' ? Math.max(1, maxRound - 3) : 1; + var visible = decisions.filter(function (item) { return item.round >= minimumRound; }); + if (view === 'left' || view === 'right') { + visible = visible.filter(function (item, index) { return item.round === maxRound || index % 2 === (view === 'left' ? 0 : 1); }); + } + var grouped = {}; + visible.forEach(function (item) { (grouped[item.round] || (grouped[item.round] = [])).push(item); }); + var positions = {}; + Object.keys(grouped).forEach(function (roundKey) { + var round = Number(roundKey), items = grouped[round], count = items.length; + items.forEach(function (item, index) { + var centered = layout === 'symmetric'; + var side = centered && round < maxRound && index % 2 ? 1 : -1; + var depth = maxRound - round; + var x = centered ? 375 + side * Math.min(250, 82 + depth * 68) : 64 + (round - minimumRound) * (590 / Math.max(1, maxRound - minimumRound)); + var y = 300 + (index + 1) * (440 / (count + 1)); + positions[item.winner_id + ':' + item.round] = { x: x, y: y, side: side, item: item }; + }); + }); + ctx.setLineWidth(3); + ctx.setStrokeStyle(theme.accent); + Object.keys(positions).forEach(function (key) { + var pos = positions[key], parent = null; + visible.forEach(function (candidate) { + if (!parent && candidate.round > pos.item.round && (candidate.left_id === pos.item.winner_id || candidate.right_id === pos.item.winner_id)) parent = positions[candidate.winner_id + ':' + candidate.round]; + }); + if (parent) { + ctx.beginPath(); ctx.moveTo(pos.x, pos.y); ctx.lineTo((pos.x + parent.x) / 2, pos.y); ctx.lineTo((pos.x + parent.x) / 2, parent.y); ctx.lineTo(parent.x, parent.y); ctx.stroke(); + } + }); + Object.keys(positions).forEach(function (key) { + var pos = positions[key], label = names[pos.item.winner_id] || result.champion_name; + ctx.setFillStyle(pos.item.round === maxRound ? theme.accent : theme.bg); + ctx.fillRect(pos.x - 58, pos.y - 21, 116, 42); + ctx.setFillStyle(theme.ink); ctx.setFontSize(label.length > 6 ? 14 : 17); + ctx.fillText(label.slice(0, 8), pos.x - 48, pos.y + 6); + }); + ctx.setFillStyle(theme.ink); ctx.setFontSize(24); ctx.fillText('冠军 ' + result.champion_name, 54, 850); +} diff --git a/pages/result/result.xhsml b/pages/result/result.xhsml index 8a88d86..a20523b 100644 --- a/pages/result/result.xhsml +++ b/pages/result/result.xhsml @@ -1,2 +1,18 @@ -FINAL RICE本命饭出炉!♛48 种饭的最终冠军{{result.champion_name}}决赛击败 {{result.runner_up_name}}分享卡片3 款模板手绘贴纸{{sticker?'开启 ✓':'关闭'}} - + + 分享图编辑器{{result.test_title}} · {{result.champion_name}} + + + + + + + + + 晋级图 + + {{index + 1}}{{item.label}} + + + 装饰贴纸{{sticker ? '已开启' : '关闭'}} + 此工具将由 @entroia/poster-editor 的图层绑定驱动 + diff --git a/pages/test/test.css b/pages/test/test.css index 0d84b4e..b053fcf 100644 --- a/pages/test/test.css +++ b/pages/test/test.css @@ -1,2 +1,28 @@ -.top{display:flex;justify-content:space-between}.progress{height:10rpx;margin:20rpx 0 46rpx;overflow:hidden;border-radius:99rpx;background:#eadfd7}.progress view{height:100%;background:#ff6b35}.title{margin-bottom:34rpx;font-size:54rpx;font-weight:900}.pair{position:relative}.choice{position:relative;min-height:350rpx;margin-bottom:24rpx;padding:34rpx;box-sizing:border-box;border-radius:38rpx;color:#fff}.a{background:#ff6b35;transform:rotate(-1deg)}.b{background:#2766df;transform:rotate(1deg)}.letter{display:block;width:54rpx;height:54rpx;line-height:54rpx;text-align:center;border:2rpx solid rgba(255,255,255,.6);border-radius:50%;font-weight:900}.emoji{position:absolute;right:30rpx;top:20rpx;font-size:92rpx}.name{display:block;margin-top:65rpx;font-size:62rpx;font-weight:900}.hint{position:absolute;left:34rpx;bottom:30rpx;opacity:.7}.vs{position:absolute;z-index:2;left:50%;top:50%;width:82rpx;height:82rpx;line-height:82rpx;text-align:center;transform:translate(-50%,-50%) rotate(-8deg);border:5rpx solid #25211f;border-radius:50%;background:#fff;font-weight:900} - +.arena { height: 100vh; overflow: hidden; padding: 24rpx 28rpx; background: #fff; } +.topline { display: flex; align-items: center; justify-content: space-between; font-size: 23rpx; font-weight: 800; } +.progress { margin-top: 14rpx; height: 7rpx; overflow: hidden; background: #e8e8e8; } +.progress-in { height: 100%; background: #111; transition: width .25s; } +.mode-tabs { display: flex; width: 430rpx; height: 62rpx; margin: 24rpx auto; padding: 5rpx; border-radius: 10rpx; background: #f1f1f1; } +.mode { flex: 1; display: grid; place-items: center; border-radius: 7rpx; color: #777; font-size: 23rpx; } +.mode.active { background: #111; color: #fff; font-weight: 800; } +.single-stage { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 16rpx; height: calc(100vh - 190rpx); max-height: 850rpx; } +.choice { position: relative; display: flex; flex-direction: column; justify-content: center; min-width: 0; padding: 28rpx; color: #fff; transition: transform .2s cubic-bezier(.2,.9,.2,1), opacity .2s; } +.choice:active { transform: scale(.94) rotate(-1deg); opacity: .8; } +.choice-a { background: #f34b30; } +.choice-b { background: #3564dd; } +.choice-index { position: absolute; left: 24rpx; top: 24rpx; width: 48rpx; height: 48rpx; line-height: 48rpx; text-align: center; border: 2rpx solid rgba(255,255,255,.6); border-radius: 50%; font-size: 21rpx; } +.choice-name { font-size: 45rpx; line-height: 1.1; font-weight: 950; word-break: break-all; } +.tap-hint { position: absolute; left: 28rpx; bottom: 28rpx; font-size: 21rpx; opacity: .75; } +.versus { position: absolute; z-index: 3; left: 50%; top: 50%; transform: translate(-50%, -50%); display: grid; place-items: center; width: 66rpx; height: 66rpx; border: 5rpx solid #111; border-radius: 50%; background: #fff; font-size: 21rpx; font-weight: 950; } +.swipe-stage { perspective: 1000rpx; } +.swipe-stage .choice-a { transform: rotate(-2deg); transform-origin: bottom right; } +.swipe-stage .choice-b { transform: rotate(2deg); transform-origin: bottom left; } +.multi-stage { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 12rpx; height: calc(100vh - 190rpx); max-height: 850rpx; } +.mini-match { position: relative; display: grid; grid-template-columns: 1fr 42rpx 1fr; align-items: stretch; min-height: 0; overflow: hidden; border: 2rpx solid #111; } +.mini-choice { display: grid; place-items: center; padding: 16rpx; text-align: center; font-size: 27rpx; font-weight: 850; background: #fff; transition: .18s; } +.mini-choice:active { color: #fff; background: #111; transform: scale(.95); } +.mini-vs { display: grid; place-items: center; background: #111; color: #fff; font-size: 17rpx; font-weight: 900; } +.mini-winner { grid-column: 1 / 4; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: winner-in .35s cubic-bezier(.2,1.3,.4,1); background: #111; color: #fff; font-size: 34rpx; font-weight: 950; } +.mini-winner text { margin-bottom: 10rpx; color: #d9ff54; font-size: 18rpx; letter-spacing: 4rpx; } +.saving { position: fixed; z-index: 10; left: 50%; bottom: 50rpx; transform: translateX(-50%); padding: 20rpx 30rpx; border-radius: 99rpx; background: #111; color: #fff; font-size: 23rpx; } +@keyframes winner-in { from { opacity: 0; transform: scale(.65) rotate(-4deg); } to { opacity: 1; transform: scale(1); } } diff --git a/pages/test/test.js b/pages/test/test.js index f4471b2..ccd9386 100644 --- a/pages/test/test.js +++ b/pages/test/test.js @@ -1,9 +1,114 @@ -var api=require('../../utils/api'),bracket=require('../../utils/bracket'); -Page({ - data:{loading:true,left:null,right:null,round:1,progress:0,completed:0,total:47}, - onLoad:function(){var self=this;api.request('/api/v1/tests/rice').then(function(test){self.test=test;self.state=bracket.create(test.candidates);self.refresh();self.setData({loading:false,total:test.candidates.length-1});}).catch(function(error){self.setData({loading:false,error:error.message});});}, - refresh:function(){var pair=bracket.current(this.state);this.setData({left:pair&&pair[0],right:pair&&pair[1],round:this.state.round,completed:this.state.decisions.length,progress:Math.round(this.state.decisions.length/this.data.total*100)});}, - choose:function(event){if(this.busy)return;this.busy=true;var winner=event.currentTarget.dataset.side==='left'?this.data.left:this.data.right,finished=bracket.choose(this.state,winner);if(finished)this.complete();else{this.refresh();this.busy=false;}}, - complete:function(){var self=this,champion=this.state.pool[0],final=this.state.decisions[this.state.decisions.length-1],runner=final.left_id===champion.id?final.right_id:final.left_id;api.ensureLogin().then(function(){return api.request('/api/v1/tests/rice/runs',{method:'POST',data:{app_key:'rice',champion_id:champion.id,runner_up_id:runner,ranking_ids:bracket.ranking(self.state),decisions:self.state.decisions}});}).then(function(result){xhs.setStorageSync('rice_last_result',result);xhs.redirectTo({url:'/pages/result/result?id='+result.id});}).catch(function(error){self.busy=false;xhs.showModal({title:'保存失败',content:error.message||'请重试',showCancel:false});});} -}); +var api = require('../../utils/api'); +var bracket = require('../../utils/bracket'); +Page({ + data: { loading: true, error: '', test: null, left: null, right: null, matches: [], mode: 'single', round: 1, progress: 0, completed: 0, totalDecisions: 0 }, + onLoad: function (options) { + this.slug = options.slug || 'rice'; + this.load(); + }, + load: function () { + var self = this; + api.request('/api/v1/tests/' + this.slug).then(function (test) { + self.test = test; + self.state = bracket.create(test.candidates); + self.totalDecisions = test.candidates.length - 1; + self.refreshPair(); + self.setData({ loading: false, test: test, totalDecisions: self.totalDecisions }); + }).catch(function (error) { self.setData({ loading: false, error: error.message || '加载失败' }); }); + }, + refreshPair: function () { + var pair = bracket.current(this.state); + if (this.visibleRound !== this.state.round) { + this.visibleRound = this.state.round; + this.multiOffset = 0; + } + var offset = this.multiOffset || 0; + var matches = this.state.matches.slice(offset, offset + 4).map(function (match, index) { + return { index: offset + index, left: match.left, right: match.right, winner: match.winner }; + }); + this.setData({ + left: pair && pair[0], + right: pair && pair[1], + matches: matches, + round: this.state.round, + completed: this.state.decisions.length, + progress: Math.round(this.state.decisions.length / this.totalDecisions * 100) + }); + }, + setMode: function (event) { + this.setData({ mode: event.currentTarget.dataset.mode }); + }, + choose: function (event) { + if (this.choosing) return; + this.choosing = true; + var side = event.currentTarget.dataset.side; + var winner = side === 'left' ? this.data.left : this.data.right; + var finished = bracket.choose(this.state, winner); + if (finished) this.complete(); + else { + this.refreshPair(); + var self = this; + setTimeout(function () { self.choosing = false; }, 220); + } + }, + chooseMatch: function (event) { + if (this.choosing) return; + var matchIndex = Number(event.currentTarget.dataset.match); + var side = event.currentTarget.dataset.side; + var match = this.state.matches[matchIndex]; + if (!match || match.winner || !match.right) return; + this.choosing = true; + var winner = side === 'left' ? match.left : match.right; + var beforeRound = this.state.round; + var finished = bracket.chooseAt(this.state, matchIndex, winner); + if (finished) this.complete(); + else { + this.refreshPair(); + var self = this; + setTimeout(function () { + if (beforeRound === self.state.round) { + var offset = self.multiOffset || 0; + var block = self.state.matches.slice(offset, offset + 4); + if (block.length && block.every(function (item) { return item.winner; })) self.multiOffset = offset + 4; + } + self.refreshPair(); + self.choosing = false; + }, 360); + } + }, + touchStart: function (event) { this.touchX = event.touches[0].clientX; }, + touchEnd: function (event) { + if (this.data.mode !== 'swipe' || this.touchX == null) return; + var delta = event.changedTouches[0].clientX - this.touchX; + this.touchX = null; + if (Math.abs(delta) < 55) return; + this.choose({ currentTarget: { dataset: { side: delta < 0 ? 'left' : 'right' } } }); + }, + complete: function () { + var self = this; + var champion = this.state.pool[0]; + var finalDecision = this.state.decisions[this.state.decisions.length - 1]; + var runnerUpId = finalDecision.left_id === champion.id ? finalDecision.right_id : finalDecision.left_id; + this.setData({ progress: 100, submitting: true }); + api.ensureLogin().then(function () { + return api.request('/api/v1/tests/' + self.slug + '/runs', { + method: 'POST', + data: { + app_key: 'rice', + champion_id: champion.id, + runner_up_id: runnerUpId, + ranking_ids: bracket.ranking(self.state), + decisions: self.state.decisions + } + }); + }).then(function (result) { + xhs.setStorageSync('rice_last_result', result); + xhs.redirectTo({ url: '/pages/result/result?id=' + result.id }); + }).catch(function (error) { + self.choosing = false; + self.setData({ submitting: false }); + xhs.showModal({ title: '结果保存失败', content: error.message || '请检查网络后重试', showCancel: false }); + }); + } +}); diff --git a/pages/test/test.xhsml b/pages/test/test.xhsml index c9d562e..9c19a9a 100644 --- a/pages/test/test.xhsml +++ b/pages/test/test.xhsml @@ -1,2 +1,29 @@ -正在抽签…{{error}}第 {{round}} 轮{{completed}} / {{total}}只能留下一碗饭。A🍚{{left.name}}选它晋级 ↗VSB🍚{{right.name}}选它晋级 ↗ - + + 正在抽签… + {{error}} + + 第 {{round}} 轮{{completed}} / {{totalDecisions}} + + + 一对 + 多对 + 滑动 + + + A{{left.name}}{{mode === 'swipe' ? '向左滑选它' : '点按晋级'}} + VS + B{{right.name}}{{mode === 'swipe' ? '向右滑选它' : '点按晋级'}} + + + + 晋级{{item.winner.name}} + + {{item.left.name}} + VS + {{item.right.name}} + + + + 正在保存你的冠军… + + diff --git a/scripts/check.mjs b/scripts/check.mjs index 6dd6aa1..0e4fc79 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,2 +1 @@ -import{readFileSync,existsSync}from'node:fs';const required=['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','web/index.html','web/app.js'];for(const file of required)if(!existsSync(file))throw new Error(`Missing ${file}`);JSON.parse(readFileSync('app.json','utf8'));console.log(`Static checks passed (${required.length} required files).`); - +import{readFileSync,existsSync}from'node:fs';const required=['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','assets/test-covers/rice.jpg','vendor/poster-editor.cjs','web/index.html','web/app.js'];for(const file of required)if(!existsSync(file))throw new Error(`Missing ${file}`);JSON.parse(readFileSync('app.json','utf8'));console.log(`Static checks passed (${required.length} required files).`); diff --git a/scripts/check.py b/scripts/check.py index dedf7e1..7c9dc95 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -7,6 +7,8 @@ required = [ "pages/index/index.xhsml", "pages/test/test.js", "pages/result/result.js", + "assets/test-covers/rice.jpg", + "vendor/poster-editor.cjs", "web/index.html", "web/app.js", ] diff --git a/test/bracket.test.cjs b/test/bracket.test.cjs new file mode 100644 index 0000000..ea65209 --- /dev/null +++ b/test/bracket.test.cjs @@ -0,0 +1,25 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const bracket = require('../utils/bracket'); + +function candidates(count) { + return Array.from({ length: count }, (_, index) => ({ id: String(index), name: `C${index}` })); +} + +test('supports selecting several matches in the same round', () => { + const state = bracket.create(candidates(4)); + assert.equal(bracket.chooseAt(state, 1, state.matches[1].right), false); + assert.equal(state.round, 1); + assert.equal(bracket.chooseAt(state, 0, state.matches[0].left), false); + assert.equal(state.round, 2); + assert.equal(bracket.choose(state, state.matches[0].left), true); + assert.equal(state.pool.length, 1); + assert.equal(state.decisions.length, 3); +}); + +test('odd candidates receive a deterministic bye', () => { + const state = bracket.create(candidates(3)); + bracket.chooseAt(state, 0, state.matches[0].left); + assert.equal(state.round, 2); + assert.equal(state.matches[0].right.id, '2'); +}); diff --git a/utils/bracket.js b/utils/bracket.js index dcbc566..2993228 100644 --- a/utils/bracket.js +++ b/utils/bracket.js @@ -1,6 +1,48 @@ -function create(candidates){return{round:1,pool:candidates.slice(),pairIndex:0,winners:[],eliminated:[],decisions:[]};} -function current(state){if(state.pool.length===1)return null;return[state.pool[state.pairIndex],state.pool[state.pairIndex+1]||null];} -function choose(state,winner){var pair=current(state);if(!pair||!pair[1])throw new Error('invalid bracket pair');var loser=pair[0].id===winner.id?pair[1]:pair[0];state.winners.push(winner);state.eliminated.unshift(loser);state.decisions.push({round:state.round,left_id:pair[0].id,right_id:pair[1].id,winner_id:winner.id});state.pairIndex+=2;if(state.pairIndex>>0;} +function glyphAngle(seed,layer,index,max){return((hash(seed+':'+layer+':'+index)/4294967295)*2-1)*Math.abs(max||0);} +function createDocument(options){options=options||{};return{schema:'entroia.poster-document',version:1,id:options.id||('doc-'+Date.now()),seed:options.seed||('seed-'+Date.now()),canvas:{width:options.width||1080,height:options.height||1440,background:options.background||'#fff'},layers:options.layers||[],bindings:options.bindings||{},meta:options.meta||{}};} +module.exports={stableHash:hash,deterministicGlyphAngle:glyphAngle,createDocument:createDocument};