Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49efa21af3 | ||
|
|
c8507aed43 | ||
|
|
87b8949f95 | ||
|
|
1626b6f498 | ||
|
|
68b5b12b59 | ||
|
|
9e684f0afb | ||
|
|
8a6890767e | ||
|
|
d57e60539e | ||
|
|
78fd00fa05 | ||
|
|
fd7e6071a8 | ||
|
|
7192c4db8c |
@@ -22,3 +22,5 @@ unpackage/dist
|
||||
*.sw?
|
||||
|
||||
TUICallKit
|
||||
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
|
||||
!TUICallKit
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import { ref} from "vue";
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError } from '@dcloudio/uni-app'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
@@ -97,11 +96,7 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('SDKAppID',SDKAppID)
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
@@ -115,4 +110,4 @@ const video =async (data)=>{
|
||||
uni.setStorageSync('CallManager', userId)
|
||||
await uni.CallManager.init(cc);
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../wx/TUICallKit
|
||||
@@ -239,7 +239,6 @@
|
||||
import messageEnhancerPlugin from '@tencentcloud/lite-chat/plugins/message-enhancer';
|
||||
import richMediaMessagePlugin from '@tencentcloud/lite-chat/plugins/rich-media-message';
|
||||
import groupPlugin from '@tencentcloud/lite-chat/plugins/group';
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { TUICallKitAPI } from '@/TUICallKit/src/TUICallService/index';
|
||||
import { TUIStore } from '@/TUICallKit/src/TUICallService/CallService/index';
|
||||
import { StoreName, NAME, CallStatus } from '@/TUICallKit/src/TUICallService/const/index';
|
||||
@@ -561,17 +560,12 @@ const { proxy } = getCurrentInstance()
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
patient_data.value=res.data.data
|
||||
const { userSig, SDKAppID,secretKey } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
getApp().globalData.secretKey = secretKey;
|
||||
uni.setStorageSync('CallManager', userId)
|
||||
// TUICallKit 必须在 TIM chat 登录成功后再 init,且必须传入 tim,否则无法接收通话信令(聊天正常、视频接不到)
|
||||
}
|
||||
|
||||
@@ -186,16 +186,8 @@ const inlineItems = computed(() => {
|
||||
return metro ? [metro, ...rest] : trainingItems
|
||||
})
|
||||
|
||||
const isDevMode = (): boolean => {
|
||||
/* HBuilderX 工程:发行(release)模式下 NODE_ENV === 'production' */
|
||||
try {
|
||||
return process.env.NODE_ENV !== 'production'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const visible = ref(isDevMode())
|
||||
// 居家耗糖训练是正式功能,开发版和发行版都需要展示。
|
||||
const visible = ref(true)
|
||||
const expanded = ref(false)
|
||||
|
||||
const toggle = () => {
|
||||
|
||||
@@ -178,7 +178,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -379,10 +378,11 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const { userId: patientUserId } = res.data
|
||||
const { userSig: patientUserSig, SDKAppID: patientSDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID: patientUserId,
|
||||
})
|
||||
const {
|
||||
userId: patientUserId,
|
||||
userSig: patientUserSig,
|
||||
sdkAppId: patientSDKAppID,
|
||||
} = res.data
|
||||
console.log('获取签名成功:', patientUserSig)
|
||||
|
||||
// 更新ref值
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx79b9a0bfbfe7cbcd",
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true
|
||||
@@ -62,7 +63,7 @@
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"version" : "0.3.9",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,14 +26,6 @@
|
||||
"navigationBarTitleText": "甄养堂"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "TUICallKit/src/Components/TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/order/monad/monad",
|
||||
"style": {
|
||||
@@ -76,6 +68,19 @@
|
||||
}
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "TUICallKit/src/Components",
|
||||
"pages": [
|
||||
{
|
||||
"path": "TUICallKit",
|
||||
"style": {
|
||||
"navigationBarTitleText": "视频问诊",
|
||||
"navigationBarHidden": true,
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"root": "TUIKit",
|
||||
"pages": [
|
||||
@@ -174,7 +179,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -191,7 +196,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -208,29 +213,22 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/game",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "糖分突袭",
|
||||
"backgroundColor": "#c7d2fe",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
import { ref,onMounted,getCurrentInstance} from "vue";
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
const { proxy } = getCurrentInstance()
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
import { onLaunch, onShow, onHide, onError ,onShareAppMessage} from '@dcloudio/uni-app'
|
||||
let userID = ref("4");
|
||||
@@ -140,13 +139,9 @@ const loginHandler = async (patient_id) => {
|
||||
|
||||
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('获取签名成功:',userSig);
|
||||
getApp().globalData.userID = userId.value;
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
getApp().globalData.SDKAppID = SDKAppID;
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
<script setup>
|
||||
import { ref, getCurrentInstance,onMounted } from "vue";
|
||||
import { onShow, onShareAppMessage } from '@dcloudio/uni-app'
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
let userId = ref("");
|
||||
@@ -126,10 +125,7 @@ const loginHandler = async (patient_id) => {
|
||||
data: { patient_id: patient_id },
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
console.log('获取签名成功:',userSig);
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
|
||||
@@ -370,7 +370,6 @@
|
||||
import { ref,onMounted,getCurrentInstance } from "vue";
|
||||
import { onShow, onShareAppMessage,onLaunch } from '@dcloudio/uni-app'
|
||||
// 2. 获取组件实例,通过 proxy 访问全局属性
|
||||
import * as GenerateTestUserSig from "@/debug/GenerateTestUserSig-es.js";
|
||||
import { CallManager } from "@/TUICallKit/src/TUICallService/serve/callManager";
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
@@ -667,25 +666,25 @@ const closePatientModal = async () => {
|
||||
const granted = await requestAVPermissionsInModal();
|
||||
if (!granted) return; // 未授权,停止跳转
|
||||
const app = getApp();
|
||||
// const userID = app.globalData.userID;
|
||||
// const userSig = app.globalData.userSig;
|
||||
// const SDKAppID = app.globalData.SDKAppID;
|
||||
const doctor = 'doctor_' + doctorId.value;
|
||||
await confirmDiagnosis(diagnosisId);
|
||||
if(!diagnosisId.includes('patient')){
|
||||
diagnosisId='patient_'+diagnosisId
|
||||
}
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:diagnosisId,
|
||||
});
|
||||
const signatureRes = await proxy.apiUrl({
|
||||
url: '/api/tcm/getPatientSignature',
|
||||
method: 'GET',
|
||||
data: { patient_id: diagnosisId },
|
||||
}, false);
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = signatureRes.data;
|
||||
if (!doctorId.value) {
|
||||
uni.showToast({ title: '诊室号不存在,联系助理处理!', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(app.globalData, diagnosisId);
|
||||
app.globalData.userID = userId;
|
||||
app.globalData.userSig = userSig;
|
||||
app.globalData.SDKAppID = SDKAppID;
|
||||
console.log(app.globalData, userId);
|
||||
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(diagnosisId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
const url=`/TUIKit/pages/chat/chat?userID=${encodeURIComponent(userId)}&userSig=${encodeURIComponent(userSig)}&SDKAppID=${SDKAppID}&targetUserID=${doctor}&msg=1`
|
||||
|
||||
showPatientModal.value = false;
|
||||
uni.redirectTo({
|
||||
@@ -707,11 +706,7 @@ const video =async (data)=>{
|
||||
},
|
||||
}, false)
|
||||
|
||||
const {userId } = res.data;
|
||||
|
||||
const { userSig, SDKAppID } = GenerateTestUserSig.genTestUserSig({
|
||||
userID:userId,
|
||||
});
|
||||
const { userId, userSig, sdkAppId: SDKAppID } = res.data;
|
||||
|
||||
getApp().globalData.userID = userId;
|
||||
getApp().globalData.userSig = userSig;
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<view
|
||||
class="food-tile-icon"
|
||||
:class="[`food-tile-icon--${size}`, { 'is-custom-img': hasCustomImg }]"
|
||||
>
|
||||
<text v-if="showEmojiFallback" class="food-tile-icon__emoji">{{ fallbackIcon }}</text>
|
||||
<image
|
||||
v-if="src && !imageFailed"
|
||||
class="food-tile-icon__img"
|
||||
:src="src"
|
||||
:mode="imageMode"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getFoodImgUrl } from '../data/gameFoodImg.js'
|
||||
|
||||
const props = defineProps({
|
||||
food: { type: [Object, String], default: null },
|
||||
size: { type: String, default: 'tile' }
|
||||
})
|
||||
|
||||
const imageFailed = ref(false)
|
||||
|
||||
const fallbackIcon = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.icon) return props.food.icon
|
||||
return '🍽'
|
||||
})
|
||||
|
||||
const hasCustomImg = computed(() => typeof props.food === 'object' && !!props.food?.img)
|
||||
|
||||
const src = computed(() => {
|
||||
if (typeof props.food === 'object' && props.food?.img) return props.food.img
|
||||
if (typeof props.food === 'object' && props.food?.icon) return getFoodImgUrl(props.food.icon)
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 有可用图片时不渲染 emoji,避免 PNG 透明区域露出底层符号 */
|
||||
const showEmojiFallback = computed(() => !src.value || imageFailed.value)
|
||||
|
||||
/** 自定义素材用 aspectFill 铺满,减少边缘露底 */
|
||||
const imageMode = computed(() => (hasCustomImg.value ? 'aspectFill' : 'aspectFit'))
|
||||
|
||||
watch(
|
||||
() => src.value,
|
||||
() => {
|
||||
imageFailed.value = false
|
||||
}
|
||||
)
|
||||
|
||||
function onImageError() {
|
||||
imageFailed.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.food-tile-icon {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: calc(var(--tile-emoji-size, 48px) * 0.6);
|
||||
}
|
||||
|
||||
.food-tile-icon--tile .food-tile-icon__emoji,
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 75%;
|
||||
height: 75%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.food-tile-icon--tile.is-custom-img .food-tile-icon__img {
|
||||
width: 82%;
|
||||
height: 82%;
|
||||
}
|
||||
|
||||
/* 玻璃格上的食材图片:投影增强立体感(参考稿) */
|
||||
.food-tile-icon--tile .food-tile-icon__img {
|
||||
filter: drop-shadow(0 6rpx 10rpx rgba(0, 0, 0, 0.16));
|
||||
}
|
||||
|
||||
.food-tile-icon--goal {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon--tooltip {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.food-tile-icon__emoji {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal .food-tile-icon__emoji,
|
||||
.food-tile-icon--tooltip .food-tile-icon__emoji {
|
||||
position: static;
|
||||
transform: none;
|
||||
font-size: 34rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon--goal.is-custom-img .food-tile-icon__img,
|
||||
.food-tile-icon--tooltip.is-custom-img .food-tile-icon__img {
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.food-tile-icon__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,188 +0,0 @@
|
||||
/**
|
||||
* 糖分突袭 · 视觉特效状态(飘字、粒子、连消横幅、震屏、闪屏)
|
||||
* 特效风格参考「开心消消乐」:糖果色星屑、弹性扩散光环、大消除闪屏。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
|
||||
let fxSeq = 0
|
||||
function nextFxId(prefix) {
|
||||
fxSeq += 1
|
||||
return `${prefix}-${fxSeq}-${Date.now()}`
|
||||
}
|
||||
|
||||
// 开心消消乐式糖果色板(彩虹爆裂用)
|
||||
const CANDY_COLORS = ['#FF5E9C', '#FFC93C', '#5BD16A', '#4D9DFF', '#FF8A3D', '#B57BFF', '#FF6F91', '#2FE6C8']
|
||||
// 星屑字形
|
||||
const STAR_GLYPHS = ['✦', '✧', '★', '✨']
|
||||
|
||||
export function useGameFx() {
|
||||
const floatingTexts = ref([])
|
||||
const burstParticles = ref([])
|
||||
const ripples = ref([])
|
||||
const comboBanner = ref({ show: false, text: '', tier: 1 })
|
||||
const screenShaking = ref(false)
|
||||
const flash = ref({ active: false, color: '#ffffff', opacity: 0 })
|
||||
|
||||
let comboBannerTimer = null
|
||||
let shakeTimer = null
|
||||
let flashTimer = null
|
||||
let flashFadeTimer = null
|
||||
|
||||
function spawnFloatingText(payload) {
|
||||
const id = nextFxId('ft')
|
||||
floatingTexts.value.push({ id, ...payload })
|
||||
const duration = payload.danger ? 1500 : payload.isCombo ? 1400 : 1200
|
||||
setTimeout(() => {
|
||||
floatingTexts.value = floatingTexts.value.filter((f) => f.id !== id)
|
||||
}, duration)
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 糖果爆裂粒子(星屑 + 圆点混合,上抛后受重力下落,带旋转与缓动)。
|
||||
* @param {number} cx,cy 爆裂中心(屏幕坐标)
|
||||
* @param {string} color 基础颜色
|
||||
* @param {number} count 粒子数量
|
||||
* @param {object} opts { star, rainbow, gravity, sizeMin, sizeMax, distMin, distMax, life }
|
||||
*/
|
||||
function spawnBurst(cx, cy, color, count = 8, opts = {}) {
|
||||
const {
|
||||
star = true,
|
||||
rainbow = false,
|
||||
gravity = 64,
|
||||
sizeMin = 8,
|
||||
sizeMax = 16,
|
||||
distMin = 36,
|
||||
distMax = 110,
|
||||
life = 850
|
||||
} = opts
|
||||
const dur = (life / 1000).toFixed(2)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = nextFxId('p')
|
||||
const isStar = star && Math.random() < 0.65
|
||||
const pSize = Math.random() * (sizeMax - sizeMin) + sizeMin
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const distance = Math.random() * (distMax - distMin) + distMin
|
||||
const tx = Math.cos(angle) * distance
|
||||
// 先上抛、再叠加重力下落,形成喷泉抛物线
|
||||
const ty = Math.sin(angle) * distance - distance * 0.35 + gravity
|
||||
const half = pSize / 2
|
||||
const pColor = rainbow
|
||||
? CANDY_COLORS[Math.floor(Math.random() * CANDY_COLORS.length)]
|
||||
: color
|
||||
const rot = Math.random() * 720 - 360
|
||||
const glyph = isStar ? STAR_GLYPHS[Math.floor(Math.random() * STAR_GLYPHS.length)] : ''
|
||||
|
||||
const particle = {
|
||||
id,
|
||||
left: cx - half,
|
||||
top: cy - half,
|
||||
size: pSize,
|
||||
color: pColor,
|
||||
char: glyph,
|
||||
opacity: 1,
|
||||
transform: 'scale(0.4) rotate(0deg)',
|
||||
transition: 'none'
|
||||
}
|
||||
burstParticles.value.push(particle)
|
||||
|
||||
setTimeout(() => {
|
||||
particle.transition =
|
||||
`left ${dur}s cubic-bezier(0.16,0.7,0.3,1), top ${dur}s cubic-bezier(0.3,0.1,0.4,1), opacity ${dur}s ease-in, transform ${dur}s ease-out`
|
||||
particle.left = cx - half + tx
|
||||
particle.top = cy - half + ty
|
||||
particle.opacity = 0
|
||||
particle.transform = `scale(${isStar ? 1.15 : 0.5}) rotate(${rot}deg)`
|
||||
}, 16)
|
||||
|
||||
setTimeout(() => {
|
||||
burstParticles.value = burstParticles.value.filter((p) => p.id !== id)
|
||||
}, life + 80)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹性扩散发光光环(开心消消乐式的扩散光圈)。
|
||||
* @param {object} opts { thickness, life }
|
||||
*/
|
||||
function spawnRipple(cx, cy, color, tier = 1, opts = {}) {
|
||||
const id = nextFxId('rp')
|
||||
const size = 36 + tier * 18
|
||||
const { thickness = Math.max(3, tier + 2), life = 600 } = opts
|
||||
ripples.value.push({
|
||||
id,
|
||||
left: cx,
|
||||
top: cy,
|
||||
size,
|
||||
color,
|
||||
thickness,
|
||||
opacity: 0.95
|
||||
})
|
||||
setTimeout(() => {
|
||||
ripples.value = ripples.value.filter((r) => r.id !== id)
|
||||
}, life)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏闪光(大消除瞬间的爆闪),先点亮再淡出。
|
||||
*/
|
||||
function triggerFlash(color = '#ffffff', intensity = 0.5, life = 280) {
|
||||
if (flashFadeTimer) clearTimeout(flashFadeTimer)
|
||||
if (flashTimer) clearTimeout(flashTimer)
|
||||
flash.value = { active: true, color, opacity: intensity }
|
||||
flashFadeTimer = setTimeout(() => {
|
||||
flash.value = { ...flash.value, opacity: 0 }
|
||||
}, 24)
|
||||
flashTimer = setTimeout(() => {
|
||||
flash.value = { active: false, color, opacity: 0 }
|
||||
}, life)
|
||||
}
|
||||
|
||||
function showComboBanner(chain, points) {
|
||||
if (chain <= 1) return
|
||||
const tier = chain >= 4 ? 3 : chain >= 3 ? 2 : 1
|
||||
comboBanner.value = {
|
||||
show: true,
|
||||
text: `连消 ×${chain} +${points}`,
|
||||
tier
|
||||
}
|
||||
if (comboBannerTimer) clearTimeout(comboBannerTimer)
|
||||
comboBannerTimer = setTimeout(() => {
|
||||
comboBanner.value = { ...comboBanner.value, show: false }
|
||||
}, 1100)
|
||||
}
|
||||
|
||||
function triggerShake(intensity = 1) {
|
||||
screenShaking.value = true
|
||||
if (shakeTimer) clearTimeout(shakeTimer)
|
||||
shakeTimer = setTimeout(() => {
|
||||
screenShaking.value = false
|
||||
}, intensity >= 2 ? 420 : 280)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
floatingTexts.value = []
|
||||
burstParticles.value = []
|
||||
ripples.value = []
|
||||
comboBanner.value = { show: false, text: '', tier: 1 }
|
||||
screenShaking.value = false
|
||||
flash.value = { active: false, color: '#ffffff', opacity: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
floatingTexts,
|
||||
burstParticles,
|
||||
ripples,
|
||||
comboBanner,
|
||||
screenShaking,
|
||||
flash,
|
||||
spawnFloatingText,
|
||||
spawnBurst,
|
||||
spawnRipple,
|
||||
showComboBanner,
|
||||
triggerShake,
|
||||
triggerFlash,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* 游戏进度持久化:关卡、星级、连胜、每日打卡
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { calcLevelStars } from '../data/gameLevels.js'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_progress_v1'
|
||||
|
||||
const DEFAULT = {
|
||||
currentLevel: 1,
|
||||
maxUnlocked: 1,
|
||||
stars: {},
|
||||
winStreak: 0,
|
||||
dailyStreak: 0,
|
||||
lastPlayDate: '',
|
||||
totalWins: 0,
|
||||
bestCombo: 0,
|
||||
totalStars: 0
|
||||
}
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function sumStars(starsMap) {
|
||||
return Object.values(starsMap || {}).reduce((s, n) => s + (Number(n) || 0), 0)
|
||||
}
|
||||
|
||||
function loadRaw() {
|
||||
try {
|
||||
const raw = uni.getStorageSync(STORAGE_KEY)
|
||||
if (raw && typeof raw === 'object') return { ...DEFAULT, ...raw }
|
||||
} catch (_) {}
|
||||
return { ...DEFAULT }
|
||||
}
|
||||
|
||||
function saveRaw(data) {
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, data)
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function useGameProgress() {
|
||||
const progress = ref(loadRaw())
|
||||
|
||||
function persist() {
|
||||
progress.value.totalStars = sumStars(progress.value.stars)
|
||||
saveRaw(progress.value)
|
||||
}
|
||||
|
||||
/** 进入游戏时更新每日打卡 */
|
||||
function touchDailyPlay() {
|
||||
const today = todayStr()
|
||||
const last = progress.value.lastPlayDate
|
||||
if (last === today) return progress.value.dailyStreak
|
||||
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`
|
||||
|
||||
if (last === yStr) {
|
||||
progress.value.dailyStreak = (progress.value.dailyStreak || 0) + 1
|
||||
} else {
|
||||
progress.value.dailyStreak = 1
|
||||
}
|
||||
progress.value.lastPlayDate = today
|
||||
persist()
|
||||
return progress.value.dailyStreak
|
||||
}
|
||||
|
||||
function getStars(levelId) {
|
||||
return progress.value.stars[String(levelId)] || 0
|
||||
}
|
||||
|
||||
function recordWin({ levelId, movesLeft, glucoseLevel, score, levelConfig, maxCombo }) {
|
||||
const stars = calcLevelStars({ movesLeft, glucoseLevel, score, levelConfig })
|
||||
const key = String(levelId)
|
||||
const prev = progress.value.stars[key] || 0
|
||||
if (stars > prev) progress.value.stars[key] = stars
|
||||
|
||||
progress.value.winStreak = (progress.value.winStreak || 0) + 1
|
||||
progress.value.totalWins = (progress.value.totalWins || 0) + 1
|
||||
if (maxCombo > (progress.value.bestCombo || 0)) progress.value.bestCombo = maxCombo
|
||||
|
||||
const next = levelId + 1
|
||||
if (next > progress.value.maxUnlocked) progress.value.maxUnlocked = next
|
||||
progress.value.currentLevel = next
|
||||
persist()
|
||||
return { stars, newBest: stars > prev }
|
||||
}
|
||||
|
||||
function recordLoss() {
|
||||
progress.value.winStreak = 0
|
||||
persist()
|
||||
}
|
||||
|
||||
function setCurrentLevel(levelId) {
|
||||
progress.value.currentLevel = Math.max(1, Math.min(progress.value.maxUnlocked, levelId))
|
||||
persist()
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
touchDailyPlay,
|
||||
getStars,
|
||||
recordWin,
|
||||
recordLoss,
|
||||
setCurrentLevel,
|
||||
persist
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/**
|
||||
* 糖分突袭 · 游戏音效(InnerAudioContext 池化,H5/小程序通用)
|
||||
* 每种操作独立音轨 + playbackRate 变调,避免“全是同一个声”
|
||||
*/
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'tongji_game_sfx_enabled'
|
||||
const COS = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/file'
|
||||
|
||||
/** 9 条基础素材(已上传 COS,小程序域名白名单内) */
|
||||
const SRC = {
|
||||
tap: `${COS}/20260526/20260526105128557ef8669.mp3`,
|
||||
swoosh: `${COS}/20260527/202605271539371ebe13672.mp3`,
|
||||
pop: `${COS}/20260526/202605261051282a0a94508.mp3`,
|
||||
buzz: `${COS}/20260527/202605271539376ff628472.mp3`,
|
||||
chime: `${COS}/20260528/202605280937077bad76716.mp3`,
|
||||
fanfare: `${COS}/20260528/20260528093707b021f5794.mp3`,
|
||||
blast: `${COS}/20260528/20260528093707ebd4d5733.mp3`,
|
||||
sparkle: `${COS}/20260528/20260528093707016f07427.mp3`,
|
||||
stinger: `${COS}/20260528/20260528093709c669f4659.mp3`
|
||||
}
|
||||
|
||||
/**
|
||||
* 音效表:src / volume / rate(playbackRate) / pool
|
||||
* rate 不同可在同一素材上听出明显差异
|
||||
*/
|
||||
const SFX = {
|
||||
// —— 交互 ——
|
||||
select: { src: SRC.tap, volume: 0.32, rate: 1.05, pool: 3 },
|
||||
deselect: { src: SRC.tap, volume: 0.22, rate: 0.82, pool: 2 },
|
||||
hint: { src: SRC.tap, volume: 0.26, rate: 1.28, pool: 2 },
|
||||
|
||||
swap: { src: SRC.swoosh, volume: 0.38, rate: 1.0, pool: 3 },
|
||||
swapFail: { src: SRC.buzz, volume: 0.36, rate: 1.18, pool: 2 },
|
||||
|
||||
drop: { src: SRC.pop, volume: 0.3, rate: 0.88, pool: 4 },
|
||||
dropLight: { src: SRC.pop, volume: 0.22, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 消除(按连数)——
|
||||
match3: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
match4: { src: SRC.fanfare, volume: 0.54, rate: 1.06, pool: 3 },
|
||||
match5: { src: SRC.blast, volume: 0.62, rate: 1.0, pool: 3 },
|
||||
|
||||
// —— 消除(按 GI 主色)——
|
||||
matchLowGi: { src: SRC.chime, volume: 0.42, rate: 0.86, pool: 2 },
|
||||
matchMidGi: { src: SRC.chime, volume: 0.46, rate: 1.02, pool: 2 },
|
||||
matchHighGi: { src: SRC.buzz, volume: 0.44, rate: 0.92, pool: 2 },
|
||||
|
||||
// —— 连消 ——
|
||||
combo2: { src: SRC.fanfare, volume: 0.56, rate: 1.12, pool: 2 },
|
||||
combo3: { src: SRC.blast, volume: 0.64, rate: 1.08, pool: 2 },
|
||||
comboMega: { src: SRC.blast, volume: 0.72, rate: 1.22, pool: 2 },
|
||||
|
||||
// —— 道具 ——
|
||||
insulin: { src: SRC.sparkle, volume: 0.5, rate: 1.15, pool: 2 },
|
||||
fiber: { src: SRC.swoosh, volume: 0.48, rate: 1.32, pool: 2 },
|
||||
meal: { src: SRC.stinger, volume: 0.42, rate: 1.35, pool: 1 },
|
||||
reshuffle: { src: SRC.swoosh, volume: 0.46, rate: 0.72, pool: 2 },
|
||||
|
||||
// —— 反馈 ——
|
||||
score: { src: SRC.tap, volume: 0.28, rate: 1.38, pool: 2 },
|
||||
goalTick: { src: SRC.chime, volume: 0.36, rate: 1.45, pool: 2 },
|
||||
meterWarn: { src: SRC.buzz, volume: 0.5, rate: 1.0, pool: 2 },
|
||||
meterDanger: { src: SRC.buzz, volume: 0.58, rate: 0.78, pool: 2 },
|
||||
meterStable: { src: SRC.pop, volume: 0.24, rate: 1.05, pool: 1 },
|
||||
|
||||
win: { src: SRC.fanfare, volume: 0.66, rate: 1.0, pool: 1 },
|
||||
winStinger: { src: SRC.stinger, volume: 0.38, rate: 1.5, pool: 1 },
|
||||
lose: { src: SRC.buzz, volume: 0.52, rate: 0.68, pool: 1 },
|
||||
|
||||
// 兼容旧名
|
||||
match: { src: SRC.chime, volume: 0.48, rate: 1.0, pool: 4 },
|
||||
combo: { src: SRC.fanfare, volume: 0.56, rate: 1.1, pool: 3 },
|
||||
heal: { src: SRC.pop, volume: 0.44, rate: 1.18, pool: 2 },
|
||||
boost: { src: SRC.sparkle, volume: 0.52, rate: 1.0, pool: 2 },
|
||||
invalid: { src: SRC.buzz, volume: 0.4, rate: 1.15, pool: 2 },
|
||||
danger: { src: SRC.blast, volume: 0.58, rate: 0.95, pool: 2 }
|
||||
}
|
||||
|
||||
class SfxPool {
|
||||
constructor(src, size, volume, rate = 1) {
|
||||
this.src = src
|
||||
this.size = size
|
||||
this.volume = volume
|
||||
this.rate = rate
|
||||
this.list = []
|
||||
this.cursor = 0
|
||||
this.warmedUp = false
|
||||
}
|
||||
|
||||
create() {
|
||||
for (let i = 0; i < this.size; i++) {
|
||||
const ctx = uni.createInnerAudioContext()
|
||||
ctx.src = this.src
|
||||
ctx.obeyMuteSwitch = false
|
||||
ctx.autoplay = false
|
||||
ctx.volume = this.volume
|
||||
try {
|
||||
ctx.playbackRate = this.rate
|
||||
} catch (_) {}
|
||||
this.list.push(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
warmUp() {
|
||||
if (this.warmedUp) return
|
||||
if (!this.list.length) this.create()
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.volume = 0
|
||||
ctx.play()
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.volume = this.volume
|
||||
} catch (_) {}
|
||||
}, 60)
|
||||
} catch (_) {}
|
||||
})
|
||||
this.warmedUp = true
|
||||
}
|
||||
|
||||
play(scale = 1, rateMul = 1) {
|
||||
if (!this.list.length) this.create()
|
||||
const ctx = this.list[this.cursor % this.list.length]
|
||||
this.cursor += 1
|
||||
const vol = Math.min(1, this.volume * scale)
|
||||
const rate = Math.min(2, Math.max(0.5, this.rate * rateMul))
|
||||
try {
|
||||
ctx.volume = vol
|
||||
try {
|
||||
ctx.playbackRate = rate
|
||||
} catch (_) {}
|
||||
ctx.stop()
|
||||
ctx.seek(0)
|
||||
ctx.play()
|
||||
} catch (_) {
|
||||
try {
|
||||
ctx.play()
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.list.forEach((ctx) => {
|
||||
try {
|
||||
ctx.stop()
|
||||
ctx.destroy()
|
||||
} catch (_) {}
|
||||
})
|
||||
this.list = []
|
||||
this.warmedUp = false
|
||||
}
|
||||
}
|
||||
|
||||
function dominantGi(tiles = []) {
|
||||
const cnt = { low: 0, mid: 0, high: 0 }
|
||||
tiles.forEach((t) => {
|
||||
const g = t?.type?.gi
|
||||
if (g && cnt[g] !== undefined) cnt[g] += 1
|
||||
})
|
||||
let best = 'mid'
|
||||
let max = 0
|
||||
Object.entries(cnt).forEach(([k, v]) => {
|
||||
if (v > max) {
|
||||
max = v
|
||||
best = k
|
||||
}
|
||||
})
|
||||
return best
|
||||
}
|
||||
|
||||
export function useGameSfx() {
|
||||
const enabled = ref(true)
|
||||
const pools = {}
|
||||
|
||||
try {
|
||||
const stored = uni.getStorageSync(STORAGE_KEY)
|
||||
if (stored === false || stored === '0' || stored === 0) {
|
||||
enabled.value = false
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
function ensureAudioOption() {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.setInnerAudioOption({
|
||||
obeyMuteSwitch: false,
|
||||
mixWithOther: true
|
||||
})
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
}
|
||||
|
||||
function getPool(name) {
|
||||
const cfg = SFX[name]
|
||||
if (!cfg) return null
|
||||
if (!pools[name]) {
|
||||
pools[name] = new SfxPool(cfg.src, cfg.pool, cfg.volume, cfg.rate ?? 1)
|
||||
}
|
||||
return pools[name]
|
||||
}
|
||||
|
||||
function warmUp() {
|
||||
if (!enabled.value) return
|
||||
ensureAudioOption()
|
||||
Object.keys(SFX).forEach((name) => {
|
||||
getPool(name)?.warmUp()
|
||||
})
|
||||
}
|
||||
|
||||
function play(name, scale = 1, rateMul = 1) {
|
||||
if (!enabled.value) return
|
||||
getPool(name)?.play(scale, rateMul)
|
||||
}
|
||||
|
||||
function playLayer(primary, secondary, delayMs = 70, secScale = 0.6) {
|
||||
play(primary)
|
||||
if (secondary) {
|
||||
setTimeout(() => play(secondary, secScale), delayMs)
|
||||
}
|
||||
}
|
||||
|
||||
function playMatch({ matchCount = 3, chain = 0, tiles = [] } = {}) {
|
||||
if (!enabled.value) return
|
||||
const giTrack = (() => {
|
||||
const gi = dominantGi(tiles)
|
||||
if (gi === 'low') return 'matchLowGi'
|
||||
if (gi === 'high') return 'matchHighGi'
|
||||
return 'matchMidGi'
|
||||
})()
|
||||
|
||||
if (chain >= 3) {
|
||||
playLayer('comboMega', giTrack, 90, 0.55)
|
||||
return
|
||||
}
|
||||
if (chain === 2) {
|
||||
playLayer('combo3', giTrack, 80, 0.5)
|
||||
return
|
||||
}
|
||||
if (chain === 1) {
|
||||
playLayer('combo2', giTrack, 70, 0.45)
|
||||
return
|
||||
}
|
||||
|
||||
if (matchCount >= 5) {
|
||||
playLayer('match5', 'matchHighGi', 85, 0.5)
|
||||
} else if (matchCount >= 4) {
|
||||
playLayer('match4', giTrack, 65, 0.48)
|
||||
} else {
|
||||
play(giTrack, 1, 1)
|
||||
setTimeout(() => play('match3', 0.85), 40)
|
||||
}
|
||||
}
|
||||
|
||||
function playDrop(count = 1) {
|
||||
if (!enabled.value || count <= 0) return
|
||||
const name = count >= 4 ? 'drop' : 'dropLight'
|
||||
play(name, Math.min(1.2, 0.85 + count * 0.04), count >= 6 ? 0.9 : 1)
|
||||
}
|
||||
|
||||
function playMeter(delta, level) {
|
||||
if (!enabled.value) return
|
||||
if (level > 70) play('meterDanger', 1 + (level - 70) * 0.02)
|
||||
else if (level < 30) play('meterWarn', 0.9, 0.95)
|
||||
else if (delta > 8) play('meterWarn', 0.75)
|
||||
else if (delta < -5) play('meterStable', 1.1)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
enabled.value = !enabled.value
|
||||
try {
|
||||
uni.setStorageSync(STORAGE_KEY, enabled.value ? '1' : '0')
|
||||
} catch (_) {}
|
||||
if (enabled.value) {
|
||||
warmUp()
|
||||
play('select')
|
||||
}
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
Object.keys(pools).forEach((key) => {
|
||||
pools[key]?.destroy()
|
||||
delete pools[key]
|
||||
})
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyAll()
|
||||
})
|
||||
|
||||
return {
|
||||
enabled,
|
||||
play,
|
||||
playLayer,
|
||||
playMatch,
|
||||
playDrop,
|
||||
playMeter,
|
||||
warmUp,
|
||||
toggleEnabled,
|
||||
destroyAll
|
||||
}
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
/**
|
||||
* 棋盘食材 · 仿真插画 SVG(渐变 + 高光 + 阴影,开心消消乐式 2.5D)
|
||||
*/
|
||||
import { svgToDataUrl } from '../utils/svgDataUrl.js'
|
||||
|
||||
function gid(key, suffix) {
|
||||
return `f-${String(key).replace(/[^a-z0-9]/gi, '')}-${suffix}`
|
||||
}
|
||||
|
||||
function wrap(key, body) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">${body}</svg>`
|
||||
}
|
||||
|
||||
function shadow(key) {
|
||||
const id = gid(key, 'sh')
|
||||
return `<filter id="${id}"><feDropShadow dx="0" dy="2.5" stdDeviation="2.2" flood-color="#1a1a2e" flood-opacity="0.28"/></filter>`
|
||||
}
|
||||
|
||||
function shine(x, y, rx, ry) {
|
||||
return `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" fill="#fff" opacity="0.38"/>`
|
||||
}
|
||||
|
||||
/** 圆形水果 */
|
||||
function artRound(key, base, dark, light, extra = '') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="36%" cy="30%" r="68%">
|
||||
<stop offset="0%" stop-color="${light}"/>
|
||||
<stop offset="55%" stop-color="${base}"/>
|
||||
<stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="21" ry="23" fill="url(#${g})"/>
|
||||
${shine(24, 28, 7, 5)}
|
||||
<path d="M32 13 Q35 9 37 15" stroke="#5d4037" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
<ellipse cx="38" cy="15" rx="5.5" ry="3" fill="#66bb6a" transform="rotate(28 38 15)"/>
|
||||
${extra}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 叶菜 */
|
||||
function artLeafy(key, light, mid, dark) {
|
||||
const g1 = gid(key, 'g1')
|
||||
const g2 = gid(key, 'g2')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<linearGradient id="${g1}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${mid}"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="${g2}" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${mid}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 8 C18 18 14 34 20 48 C24 54 32 56 32 56 C32 56 40 54 44 48 C50 34 46 18 32 8Z" fill="url(#${g1})"/>
|
||||
<path d="M32 14 C26 22 24 32 28 42 C30 46 32 48 32 48 C32 48 34 46 36 42 C40 32 38 22 32 14Z" fill="url(#${g2})" opacity="0.85"/>
|
||||
${shine(26, 24, 6, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西兰花 */
|
||||
function artBroccoli(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs>
|
||||
<radialGradient id="${g}" cx="50%" cy="40%" r="55%">
|
||||
<stop offset="0%" stop-color="#7cb342"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</radialGradient>
|
||||
${shadow(key)}
|
||||
</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="38" width="8" height="16" rx="3" fill="#558b2f"/>
|
||||
<circle cx="22" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="22" r="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="28" r="9" fill="url(#${g})"/><circle cx="32" cy="32" r="8" fill="#689f38"/>
|
||||
${shine(28, 20, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 长条(黄瓜/香蕉/玉米) */
|
||||
function artLong(key, base, dark, light, type = 'cucumber') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'banana') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 46 Q14 30 28 14 Q38 8 46 16 Q52 24 44 38 Q36 52 22 50 Q16 48 18 46Z" fill="url(#${g})"/>
|
||||
<path d="M24 42 Q20 32 30 20" stroke="${dark}" stroke-width="1.2" fill="none" opacity="0.5"/>
|
||||
${shine(30, 26, 5, 3)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'corn') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${base}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="14" ry="22" fill="url(#${g})"/>
|
||||
<g fill="${dark}" opacity="0.55">${Array.from({ length: 5 }, (_, r) =>
|
||||
Array.from({ length: 4 }, (_, c) =>
|
||||
`<circle cx="${22 + c * 5}" cy="${20 + r * 5}" r="1.2"/>`
|
||||
).join('')
|
||||
).join('')}</g>
|
||||
<path d="M32 10 L34 18 M32 10 L30 18" stroke="#558b2f" stroke-width="2" stroke-linecap="round"/>
|
||||
${shine(26, 26, 5, 8)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="32" rx="10" ry="24" fill="url(#${g})" transform="rotate(-12 32 32)"/>
|
||||
<ellipse cx="28" cy="22" rx="3" ry="5" fill="#fff" opacity="0.25" transform="rotate(-12 28 22)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 茄子 */
|
||||
function artEggplant(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="20%" y1="0%" x2="80%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9575cd"/><stop offset="100%" stop-color="#4527a0"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="13" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 18 Q34 12 32 8 Q30 12 32 18" fill="#558b2f"/>
|
||||
${shine(26, 32, 5, 7)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 番茄 */
|
||||
function artTomato(key) {
|
||||
return artRound(key, '#e53935', '#b71c1c', '#ef5350',
|
||||
'<path d="M24 16 Q32 12 40 16" stroke="#2e7d32" stroke-width="2.5" fill="none" stroke-linecap="round"/>')
|
||||
}
|
||||
|
||||
/** 甜椒 */
|
||||
function artPepper(key, color, dark) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="30%" y1="0%" x2="70%" y2="100%">
|
||||
<stop offset="0%" stop-color="${color}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 Q26 14 24 24 Q22 38 28 48 Q32 54 36 48 Q42 38 40 24 Q38 14 32 10Z" fill="url(#${g})"/>
|
||||
<path d="M30 12 Q32 8 34 12" stroke="#33691e" stroke-width="2" fill="none"/>
|
||||
${shine(28, 26, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 洋葱 */
|
||||
function artOnion(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="45%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#ffe0b2"/><stop offset="100%" stop-color="#bc8f5a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="18" ry="20" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q28 22 32 28 Q36 22 32 16" fill="#d7ccc8"/>
|
||||
${shine(24, 30, 6, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 葡萄串 */
|
||||
function artGrapes(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="65%">
|
||||
<stop offset="0%" stop-color="#ba68c8"/><stop offset="100%" stop-color="#6a1b9a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 10 L32 18" stroke="#558b2f" stroke-width="2"/>
|
||||
<ellipse cx="32" cy="22" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="26" cy="28" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="38" cy="28" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="22" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="32" cy="34" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="42" cy="34" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="40" rx="4" ry="4.5" fill="url(#${g})"/><ellipse cx="36" cy="40" rx="4" ry="4.5" fill="url(#${g})"/>
|
||||
${shine(28, 24, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 樱桃 */
|
||||
function artCherry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f06292"/><stop offset="100%" stop-color="#880e4f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 12 Q24 18 22 26 M32 12 Q40 18 42 26" stroke="#558b2f" stroke-width="2" fill="none"/>
|
||||
<circle cx="22" cy="32" r="9" fill="url(#${g})"/><circle cx="42" cy="32" r="9" fill="url(#${g})"/>
|
||||
${shine(19, 29, 3, 2)}${shine(39, 29, 3, 2)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 草莓 */
|
||||
function artStrawberry(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="50%" y1="0%" x2="50%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M32 14 Q18 28 22 46 Q26 54 32 56 Q38 54 42 46 Q46 28 32 14Z" fill="url(#${g})"/>
|
||||
<path d="M22 16 Q32 10 42 16 Q32 20 22 16Z" fill="#43a047"/>
|
||||
${shine(26, 28, 4, 6)}
|
||||
<g fill="#ffeb3b" opacity="0.85">${[28, 34, 38, 30, 36].map((x, i) =>
|
||||
`<circle cx="${x}" cy="${32 + (i % 3) * 5}" r="1.1"/>`
|
||||
).join('')}</g>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 豆类 */
|
||||
function artBeans(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#d7ccc8"/><stop offset="100%" stop-color="#8d6e63"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="32" rx="9" ry="12" fill="url(#${g})" transform="rotate(-20 24 32)"/>
|
||||
<ellipse cx="36" cy="30" rx="9" ry="12" fill="url(#${g})" transform="rotate(15 36 30)"/>
|
||||
<ellipse cx="32" cy="42" rx="9" ry="12" fill="url(#${g})" transform="rotate(-5 32 42)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饮品杯 */
|
||||
function artCup(key, liquid, cup = '#eceff1', type = 'glass') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'bottle') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="24" y="14" width="16" height="6" rx="2" fill="#b0bec5"/>
|
||||
<path d="M22 20 L24 52 Q32 56 40 52 L42 20Z" fill="url(#${g})"/>
|
||||
${shine(28, 30, 4, 10)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'wine') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stop-color="#4a0e16"/><stop offset="100%" stop-color="${liquid}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M28 18 L26 38 Q32 46 38 38 L36 18Z" fill="url(#${g})"/>
|
||||
<rect x="30" y="38" width="4" height="12" fill="#cfd8dc"/>
|
||||
<ellipse cx="32" cy="52" rx="8" ry="2" fill="#cfd8dc"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${liquid}"/><stop offset="100%" stop-color="${cup}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 24 L20 48 Q32 54 44 48 L46 24Z" fill="#eceff1" stroke="#b0bec5" stroke-width="1"/>
|
||||
<path d="M20 28 L22 46 Q32 50 42 46 L44 28Z" fill="url(#${g})"/>
|
||||
${shine(26, 32, 4, 8)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蘑菇 */
|
||||
function artMushroom(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#a1887f"/><stop offset="100%" stop-color="#5d4037"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="28" y="34" width="8" height="16" rx="3" fill="#efebe9"/>
|
||||
<ellipse cx="32" cy="30" rx="18" ry="14" fill="url(#${g})"/>
|
||||
<ellipse cx="24" cy="26" rx="3" ry="2" fill="#efebe9" opacity="0.7"/>
|
||||
<ellipse cx="36" cy="24" rx="2.5" ry="1.8" fill="#efebe9" opacity="0.7"/>
|
||||
${shine(24, 24, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 芹菜 */
|
||||
function artCelery(key) {
|
||||
return artLeafy(key, '#aed581', '#7cb342', '#558b2f')
|
||||
}
|
||||
|
||||
/** 四季豆 */
|
||||
function artGreenBean(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#33691e"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="26" cy="32" rx="5" ry="18" fill="url(#${g})" transform="rotate(-15 26 32)"/>
|
||||
<ellipse cx="38" cy="34" rx="5" ry="18" fill="url(#${g})" transform="rotate(12 38 34)"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 饭碗 */
|
||||
function artRiceBowl(key, rice, bowl = '#e0e0e0', dark = '#bdbdbd') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${rice}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 36 Q32 58 52 36 L48 48 Q32 56 16 48Z" fill="${bowl}"/>
|
||||
<ellipse cx="32" cy="36" rx="20" ry="8" fill="url(#${g})"/>
|
||||
${shine(24, 34, 6, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯类 */
|
||||
function artRoot(key, base, dark, light) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="${light}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="16" ry="20" fill="url(#${g})"/>
|
||||
<path d="M22 28 Q32 22 42 28" stroke="${base}" stroke-width="1.5" fill="none" opacity="0.4"/>
|
||||
${shine(24, 30, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 肉类 */
|
||||
function artMeat(key, base, dark, type = 'steak') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'chicken') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="30" cy="38" rx="14" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="38" cy="28" rx="8" ry="10" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="24" r="3" fill="#efebe9"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'fish') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="34" rx="20" ry="12" fill="url(#${g})"/>
|
||||
<path d="M52 34 L60 28 L60 40Z" fill="${dark}"/>
|
||||
<circle cx="22" cy="32" r="2.5" fill="#fff"/>
|
||||
${shine(28, 30, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q20 22 36 20 Q50 20 48 38 Q46 50 32 52 Q18 52 16 40Z" fill="url(#${g})"/>
|
||||
<ellipse cx="28" cy="32" rx="4" ry="3" fill="#efebe9" opacity="0.5"/>
|
||||
${shine(26, 28, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 虾/蟹 */
|
||||
function artSeafood(key, type = 'shrimp') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'crab') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff7043"/><stop offset="100%" stop-color="#bf360c"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="36" rx="14" ry="12" fill="url(#${g})"/>
|
||||
<circle cx="28" cy="32" r="2" fill="#fff"/><circle cx="36" cy="32" r="2" fill="#fff"/>
|
||||
<path d="M18 30 L10 24 M18 38 L10 44 M46 30 L54 24 M46 38 L54 44" stroke="#bf360c" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ff8a65"/><stop offset="100%" stop-color="#e64a19"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 40 Q28 16 48 28 Q42 36 38 44 Q28 52 16 40Z" fill="url(#${g})"/>
|
||||
${shine(30, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 菠萝 */
|
||||
function artPineapple(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffca28"/><stop offset="100%" stop-color="#f57f17"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="18" fill="url(#${g})"/>
|
||||
<g stroke="#e65100" stroke-width="1" opacity="0.45">${[-8, 0, 8].map(o =>
|
||||
`<line x1="${24 + o}" y1="24" x2="${24 + o}" y2="52"/>`
|
||||
).join('')}</g>
|
||||
<path d="M24 18 L32 8 L40 18" fill="#43a047"/>
|
||||
${shine(26, 32, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 奇异果 */
|
||||
function artKiwi(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#dce775"/><stop offset="60%" stop-color="#9ccc65"/><stop offset="100%" stop-color="#558b2f"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="#8d6e63"/>
|
||||
<circle cx="32" cy="34" r="14" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="4" fill="#fff" opacity="0.5"/>
|
||||
${shine(26, 28, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 蜜瓜 */
|
||||
function artMelon(key) {
|
||||
return artRound(key, '#aed581', '#689f38', '#c5e1a5', '')
|
||||
}
|
||||
|
||||
/** 蜂蜜 */
|
||||
function artHoney(key) {
|
||||
return artCup(key, '#ffb300', '#ff8f00', 'bottle')
|
||||
}
|
||||
|
||||
/** 面包类 */
|
||||
function artBread(key, base, dark, type = 'loaf') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'donut') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="34" r="18" fill="url(#${g})"/>
|
||||
<circle cx="32" cy="34" r="7" fill="#fff5f5"/>
|
||||
<path d="M20 28 Q32 18 44 28" stroke="#f48fb1" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
${shine(24, 28, 5, 4)}
|
||||
</g>`)
|
||||
}
|
||||
if (type === 'mantou') {
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="38" rx="16" ry="14" fill="url(#${g})"/>
|
||||
<path d="M18 34 Q32 26 46 34" stroke="${dark}" stroke-width="1" fill="none" opacity="0.3"/>
|
||||
${shine(24, 32, 6, 4)}
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="14" y="22" width="36" height="28" rx="8" fill="url(#${g})"/>
|
||||
${shine(22, 28, 8, 5)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 面条 */
|
||||
function artNoodle(key, soup = '#d7ccc8') {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="${soup}"/><stop offset="100%" stop-color="#a1887f"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="32" cy="42" rx="20" ry="10" fill="#eceff1"/>
|
||||
<ellipse cx="32" cy="38" rx="18" ry="8" fill="url(#${g})"/>
|
||||
<path d="M20 36 Q26 32 32 36 Q38 40 44 36" stroke="#ffe082" stroke-width="2.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M22 40 Q30 44 38 40" stroke="#ffe082" stroke-width="2" fill="none" stroke-linecap="round"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 南瓜 */
|
||||
function artPumpkin(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0%" stop-color="#ffb74d"/><stop offset="100%" stop-color="#e65100"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<ellipse cx="24" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<ellipse cx="32" cy="34" rx="11" ry="18" fill="url(#${g})"/>
|
||||
<ellipse cx="40" cy="36" rx="10" ry="16" fill="url(#${g})"/>
|
||||
<path d="M32 16 Q34 10 32 8" stroke="#558b2f" stroke-width="2.5" fill="none"/>
|
||||
${shine(26, 28, 5, 6)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 薯条 */
|
||||
function artFries(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffe082"/><stop offset="100%" stop-color="#f9a825"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M18 48 L22 24 L46 24 L50 48Z" fill="#ef5350"/>
|
||||
${[26, 32, 38, 44].map(x => `<rect x="${x}" y="18" width="4" height="22" rx="2" fill="url(#${g})"/>`).join('')}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 西瓜 */
|
||||
function artWatermelon(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ef5350"/><stop offset="100%" stop-color="#c62828"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M12 40 Q32 8 52 40 Q32 56 12 40Z" fill="#2e7d32"/>
|
||||
<path d="M16 40 Q32 14 48 40 Q32 52 16 40Z" fill="url(#${g})"/>
|
||||
${[24, 32, 40].map(x => `<circle cx="${x}" cy="36" r="1.2" fill="#212121"/>`).join('')}
|
||||
${shine(24, 30, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糖果 */
|
||||
function artCandy(key, base, dark, type = 'lollipop') {
|
||||
const g = gid(key, 'g')
|
||||
if (type === 'wrap') {
|
||||
return wrap(key, `
|
||||
<defs><linearGradient id="${g}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</linearGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<rect x="20" y="22" width="24" height="20" rx="4" fill="url(#${g})"/>
|
||||
<path d="M20 26 L16 30 L20 34 M44 26 L48 30 L44 34" stroke="${dark}" stroke-width="2" fill="none"/>
|
||||
</g>`)
|
||||
}
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="35%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="${base}"/><stop offset="100%" stop-color="${dark}"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="32" cy="28" r="14" fill="url(#${g})"/>
|
||||
<rect x="30" y="40" width="4" height="14" rx="2" fill="#eceff1"/>
|
||||
${shine(26, 24, 4, 3)}
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 糯米团 */
|
||||
function artDango(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#f8bbd0"/><stop offset="100%" stop-color="#ec407a"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<circle cx="22" cy="36" r="8" fill="#fff"/>
|
||||
<circle cx="32" cy="32" r="8" fill="url(#${g})"/>
|
||||
<circle cx="42" cy="36" r="8" fill="#fff"/>
|
||||
<rect x="14" y="44" width="36" height="3" rx="1.5" fill="#8d6e63"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
/** 爆米花 */
|
||||
function artPopcorn(key) {
|
||||
const g = gid(key, 'g')
|
||||
return wrap(key, `
|
||||
<defs><radialGradient id="${g}" cx="40%" cy="30%" r="70%">
|
||||
<stop offset="0%" stop-color="#fff9c4"/><stop offset="100%" stop-color="#fff176"/>
|
||||
</radialGradient>${shadow(key)}</defs>
|
||||
<g filter="url(#${gid(key, 'sh')})">
|
||||
<path d="M16 48 L20 36 L48 36 L52 48Z" fill="#ef5350"/>
|
||||
<circle cx="24" cy="30" r="7" fill="url(#${g})"/><circle cx="34" cy="24" r="8" fill="url(#${g})"/>
|
||||
<circle cx="44" cy="30" r="7" fill="url(#${g})"/><circle cx="32" cy="32" r="6" fill="url(#${g})"/>
|
||||
</g>`)
|
||||
}
|
||||
|
||||
const FOOD_ART = {
|
||||
lettuce: () => artLeafy('lettuce', '#c5e1a5', '#81c784', '#388e3c'),
|
||||
broccoli: () => artBroccoli('broccoli'),
|
||||
apple: () => artRound('apple', '#e53935', '#b71c1c', '#ef5350'),
|
||||
cucumber: () => artLong('cucumber', '#66bb6a', '#2e7d32', '#a5d6a7'),
|
||||
eggplant: () => artEggplant('eggplant'),
|
||||
tomato: () => artTomato('tomato'),
|
||||
pepper: () => artPepper('pepper', '#43a047', '#1b5e20'),
|
||||
onion: () => artOnion('onion'),
|
||||
pear: () => artRound('pear', '#c0ca33', '#827717', '#dce775', ''),
|
||||
orange: () => artRound('orange', '#fb8c00', '#e65100', '#ffb74d'),
|
||||
peach: () => artRound('peach', '#ff8a65', '#e64a19', '#ffab91'),
|
||||
cherry: () => artCherry('cherry'),
|
||||
grape: () => artGrapes('grape'),
|
||||
strawberry: () => artStrawberry('strawberry'),
|
||||
soybean: () => artBeans('soybean'),
|
||||
milk: () => artCup('milk', '#eceff1', '#b0bec5'),
|
||||
tea: () => artCup('tea', '#a1887f', '#6d4c41'),
|
||||
mushroom: () => artMushroom('mushroom'),
|
||||
celery: () => artCelery('celery'),
|
||||
greenBean: () => artGreenBean('greenBean'),
|
||||
brownRice: () => artRiceBowl('brownRice', '#bcaaa4', '#8d6e63', '#6d4c41'),
|
||||
sweetPotato: () => artRoot('sweetPotato', '#c75b39', '#8d2600', '#e57373'),
|
||||
taro: () => artRoot('taro', '#b39ddb', '#5e35b1', '#d1c4e9'),
|
||||
fish: () => artMeat('fish', '#4fc3f7', '#0277bd', 'fish'),
|
||||
chicken: () => artMeat('chicken', '#ffb74d', '#e65100', 'chicken'),
|
||||
beef: () => artMeat('beef', '#c0504d', '#7f0000', 'steak'),
|
||||
leanMeat: () => artMeat('leanMeat', '#b5654d', '#8d4000', 'steak'),
|
||||
shrimp: () => artSeafood('shrimp', 'shrimp'),
|
||||
crab: () => artSeafood('crab', 'crab'),
|
||||
banana: () => artLong('banana', '#fdd835', '#f9a825', '#fff176', 'banana'),
|
||||
mango: () => artRound('mango', '#ffb300', '#ff6f00', '#ffca28'),
|
||||
pineapple: () => artPineapple('pineapple'),
|
||||
kiwi: () => artKiwi('kiwi'),
|
||||
melon: () => artMelon('melon'),
|
||||
honey: () => artHoney('honey'),
|
||||
wine: () => artCup('wine', '#9b2c3b', '#4a0e16', 'wine'),
|
||||
beer: () => artCup('beer', '#ffb300', '#ff8f00'),
|
||||
coffee: () => artCup('coffee', '#6f4e37', '#3e2723'),
|
||||
corn: () => artLong('corn', '#f9c513', '#f57f17', '#fff176', 'corn'),
|
||||
udon: () => artNoodle('udon', '#d9b88f'),
|
||||
whiteRice: () => artRiceBowl('whiteRice', '#f5f5f5', '#eeeeee', '#bdbdbd'),
|
||||
whiteBread: () => artBread('whiteBread', '#d9a85c', '#a1887f', 'loaf'),
|
||||
mantou: () => artBread('mantou', '#ece0c8', '#bcaaa4', 'mantou'),
|
||||
youtiao: () => artBread('youtiao', '#d4943f', '#a1660a', 'loaf'),
|
||||
donut: () => artBread('donut', '#e87fb0', '#ad1457', 'donut'),
|
||||
popcorn: () => artPopcorn('popcorn'),
|
||||
ramen: () => artNoodle('ramen', '#e0a96d'),
|
||||
pumpkin: () => artPumpkin('pumpkin'),
|
||||
bakedPotato: () => artFries('bakedPotato'),
|
||||
watermelon: () => artWatermelon('watermelon'),
|
||||
sugar: () => artCandy('sugar', '#ff79b0', '#c2185b', 'lollipop'),
|
||||
maltose: () => artCandy('maltose', '#ffa726', '#e65100', 'wrap'),
|
||||
soda: () => artCup('soda', '#c62828', '#880e0e', 'bottle'),
|
||||
orangeJuice: () => artCup('orangeJuice', '#ff9f1c', '#e65100', 'bottle'),
|
||||
stickyRice: () => artDango('stickyRice')
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
/** 获取食材插画 data URL(带缓存) */
|
||||
export function getFoodImg(key) {
|
||||
if (imgCache[key]) return imgCache[key]
|
||||
const builder = FOOD_ART[key]
|
||||
if (!builder) return ''
|
||||
imgCache[key] = svgToDataUrl(builder())
|
||||
return imgCache[key]
|
||||
}
|
||||
|
||||
export function warmUpFoodImgs(keys) {
|
||||
keys.forEach((k) => getFoodImg(k))
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 食材图标 URL(Twemoji PNG,小程序 / H5 通用)
|
||||
* 微信小程序 <image> 不支持 SVG data URL,必须用 https PNG
|
||||
*/
|
||||
const TWEMOJI_BASE = 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72'
|
||||
|
||||
/** emoji → Twemoji PNG 地址 */
|
||||
export function emojiToTwemojiUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
const parts = []
|
||||
for (let i = 0; i < emoji.length;) {
|
||||
const cp = emoji.codePointAt(i)
|
||||
if (!cp) break
|
||||
parts.push(cp.toString(16))
|
||||
i += cp > 0xffff ? 2 : 1
|
||||
}
|
||||
return `${TWEMOJI_BASE}/${parts.join('-')}.png`
|
||||
}
|
||||
|
||||
const imgCache = {}
|
||||
|
||||
export function getFoodImgUrl(emoji) {
|
||||
if (!emoji) return ''
|
||||
if (imgCache[emoji]) return imgCache[emoji]
|
||||
const url = emojiToTwemojiUrl(emoji)
|
||||
imgCache[emoji] = url
|
||||
return url
|
||||
}
|
||||
|
||||
/** 预加载本局食材图标(小程序提前拉取,减少首屏空白) */
|
||||
export function warmUpFoodImgs(foods) {
|
||||
const list = Array.isArray(foods) ? foods : []
|
||||
list.forEach((f) => {
|
||||
const icon = typeof f === 'string' ? f : f?.icon
|
||||
// 优先预热自定义图片素材,缺省回退 emoji PNG
|
||||
const url = (typeof f === 'object' && f?.img) ? f.img : getFoodImgUrl(icon)
|
||||
if (!url) return
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
uni.getImageInfo({ src: url, fail: () => {} })
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
// #ifdef H5
|
||||
try {
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* 全量食材库:key 用于消除匹配,img 为 Twemoji PNG(小程序可用)
|
||||
*/
|
||||
import { getFoodImgUrl } from './gameFoodImg.js'
|
||||
|
||||
export const GI_LABEL = { low: '低血糖', mid: '中血糖', high: '高血糖' }
|
||||
|
||||
/** 若实际食用时的升糖幅度(教育提示用,正值=升糖) */
|
||||
export function getEatVal(food) {
|
||||
if (food.eatVal != null) return food.eatVal
|
||||
if (food.gi === 'high') return Math.abs(food.val)
|
||||
if (food.gi === 'mid') return Math.max(1, food.val)
|
||||
return Math.max(1, Math.abs(food.val))
|
||||
}
|
||||
|
||||
/** 从餐盘消除该食物后的血糖变化(负值=控糖有益) */
|
||||
export function getClearVal(food) {
|
||||
if (food.clearVal != null) return food.clearVal
|
||||
const eat = getEatVal(food)
|
||||
if (food.gi === 'high') return -Math.max(8, Math.round(eat * 0.5))
|
||||
if (food.gi === 'mid') return -1
|
||||
return 0
|
||||
}
|
||||
|
||||
/** 点击食材时的升糖说明文案 */
|
||||
export function formatEatLabel(food) {
|
||||
if (food.gi === 'low') return '升糖低'
|
||||
return `若食用 +${getEatVal(food)}`
|
||||
}
|
||||
|
||||
/** 含糖等级简称:低糖 / 中糖 / 高糖 */
|
||||
export const GI_SHORT = { low: '低糖', mid: '中糖', high: '高糖' }
|
||||
|
||||
/** 含糖等级对应的提示主色(绿 / 橙 / 红) */
|
||||
export const GI_COLOR = { low: '#16a34a', mid: '#ea580c', high: '#dc2626' }
|
||||
|
||||
/** 部分代表食物的专属科普文案(一句话,便于老人记忆) */
|
||||
const FOOD_TIP_OVERRIDE = {
|
||||
whiteRice: '精制主食,升糖快,建议小份',
|
||||
stickyRice: '糯米黏软,升糖很快,要少吃',
|
||||
whiteBread: '精制面食,升糖快',
|
||||
mantou: '白面馒头,升糖快,配菜一起吃更稳',
|
||||
youtiao: '油炸又高糖,少吃为好',
|
||||
sugar: '纯糖,升糖最快,尽量不吃',
|
||||
maltose: '糖分很高,升糖快',
|
||||
soda: '含糖饮料,升糖很快,不建议喝',
|
||||
orangeJuice: '果汁含糖高,吃整果更好',
|
||||
watermelon: '很甜,升糖快,一次别吃太多',
|
||||
donut: '又甜又油,升糖高',
|
||||
banana: '偏甜水果,适量吃',
|
||||
sweetPotato: '粗粮但有糖,可代替部分主食',
|
||||
corn: '粗粮主食,适量吃',
|
||||
honey: '本质是糖,别当健康品多吃',
|
||||
broccoli: '新鲜蔬菜,升糖很慢,可多吃',
|
||||
cucumber: '清爽蔬菜,几乎不升糖',
|
||||
lettuce: '绿叶菜,升糖很低,可多吃',
|
||||
celery: '高纤维蔬菜,升糖低',
|
||||
apple: '带皮整果,升糖较慢',
|
||||
milk: '低糖,含蛋白,适量喝好',
|
||||
fish: '优质蛋白,升糖低',
|
||||
beef: '蛋白为主,升糖低',
|
||||
milkOats: '燕麦加奶,升糖中等,选无糖燕麦更好',
|
||||
grainMantou: '杂粮做的,比白馒头稳,仍要控量',
|
||||
riceNoodleSoup: '米粉升糖快,汤粉要少吃',
|
||||
friedNoodles: '油多又是精面,升糖快',
|
||||
centuryEggCongee: '白粥熬得软烂,升糖很快,糖友要少喝',
|
||||
sandwich: '夹心面包精制碳多,升糖快',
|
||||
eightTreasureCongee: '八宝粥甜糯,升糖快,要少喝',
|
||||
milletCongee: '小米粥熬软升糖快,糖友控量',
|
||||
shandongPancake: '煎饼加油面,升糖中等偏快',
|
||||
cake: '蛋糕又甜又油,升糖快',
|
||||
biscuit: '饼干又甜又碎,升糖快',
|
||||
wonton: '馄饨面皮精白,升糖中等,别多吃',
|
||||
blackCoffee: '不加糖的黑咖啡,升糖很低'
|
||||
}
|
||||
|
||||
/** 含糖等级通用科普文案 */
|
||||
const GI_TIP_DEFAULT = {
|
||||
low: '升糖慢,相对放心,可常吃',
|
||||
mid: '升糖中等,注意分量',
|
||||
high: '含糖高、升糖快,要少吃'
|
||||
}
|
||||
|
||||
/** 点击食物时展示的一句话科普(认知教育用) */
|
||||
export function getFoodTip(food) {
|
||||
if (!food) return ''
|
||||
if (food.tip) return food.tip
|
||||
if (FOOD_TIP_OVERRIDE[food.key]) return FOOD_TIP_OVERRIDE[food.key]
|
||||
return GI_TIP_DEFAULT[food.gi] || ''
|
||||
}
|
||||
|
||||
/** 自定义图片素材目录(腾讯云 COS,优先使用真实图片而非 emoji) */
|
||||
const GAMES_IMG_BASE = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games'
|
||||
|
||||
const RAW_FOODS = [
|
||||
// 低 GI
|
||||
{ key: 'lettuce', icon: '🥬', name: '生菜', gi: 'low', val: -3, color: '#8BC34A' },
|
||||
{ key: 'broccoli', icon: '🥦', name: '西兰花', gi: 'low', val: -3, color: '#4E8A3A' },
|
||||
{ key: 'apple', icon: '🍎', name: '苹果', gi: 'low', val: -2, color: '#E53935' },
|
||||
{ key: 'cucumber', icon: '🥒', name: '黄瓜', gi: 'low', val: -4, color: '#66A82F' },
|
||||
{ key: 'eggplant', icon: '🍆', name: '茄子', gi: 'low', val: -3, color: '#7E57C2' },
|
||||
{ key: 'tomato', icon: '🍅', name: '番茄', gi: 'low', val: -2, color: '#EF5350' },
|
||||
{ key: 'pepper', icon: '🫑', name: '青椒', gi: 'low', val: -3, color: '#43A047' },
|
||||
{ key: 'onion', icon: '🧅', name: '洋葱', gi: 'low', val: -2, color: '#CDA06B' },
|
||||
{ key: 'pear', icon: '🍐', name: '雪梨', gi: 'low', val: -2, color: '#C0CA33' },
|
||||
{ key: 'orange', icon: '🍊', name: '橙子', gi: 'low', val: -2, color: '#FB8C00' },
|
||||
{ key: 'peach', icon: '🍑', name: '桃子', gi: 'low', val: -2, color: '#FF8A65' },
|
||||
{ key: 'cherry', icon: '🍒', name: '樱桃', gi: 'low', val: -2, color: '#C2185B' },
|
||||
{ key: 'grape', icon: '🍇', name: '葡萄', gi: 'low', val: -1, color: '#8E24AA' },
|
||||
{ key: 'strawberry', icon: '🍓', name: '草莓', gi: 'low', val: -2, color: '#EC407A' },
|
||||
{ key: 'soybean', icon: '🫘', name: '黄豆', gi: 'low', val: -3, color: '#C9A063' },
|
||||
{ key: 'milk', icon: '🥛', name: '牛奶', gi: 'low', val: -2, color: '#B0BEC5' },
|
||||
{ key: 'tea', icon: '🍵', name: '红茶', gi: 'low', val: -1, color: '#A1573B' },
|
||||
{ key: 'mushroom', icon: '🍄', name: '香菇', gi: 'low', val: -3, color: '#8D6E63' },
|
||||
{ key: 'celery', icon: '🌿', name: '芹菜', gi: 'low', val: -4, color: '#7CB342' },
|
||||
{ key: 'greenBean', icon: '🫛', name: '四季豆', gi: 'low', val: -3, color: '#689F38' },
|
||||
|
||||
// 中 GI
|
||||
{ key: 'brownRice', icon: '🍙', name: '糙米饭', gi: 'mid', val: 4, color: '#C9A26B' },
|
||||
{ key: 'sweetPotato', icon: '🍠', name: '番薯', gi: 'mid', val: 5, color: '#C75B39', img: `${GAMES_IMG_BASE}/%E7%95%AA%E8%96%AF.png` },
|
||||
{ key: 'taro', icon: '🥔', name: '芋头', gi: 'mid', val: 5, color: '#B39DDB' },
|
||||
{ key: 'fish', icon: '🐟', name: '鱼肉', gi: 'mid', val: 2, color: '#4FA3C7' },
|
||||
{ key: 'chicken', icon: '🍗', name: '鸡肉', gi: 'mid', val: 3, color: '#D6A15A' },
|
||||
{ key: 'beef', icon: '🥩', name: '牛肉', gi: 'mid', val: 3, color: '#C0504D' },
|
||||
{ key: 'leanMeat', icon: '🍖', name: '瘦肉', gi: 'mid', val: 3, color: '#B5654D' },
|
||||
{ key: 'shrimp', icon: '🦐', name: '虾仁', gi: 'mid', val: 2, color: '#FF7043' },
|
||||
{ key: 'crab', icon: '🦀', name: '螃蟹', gi: 'mid', val: 2, color: '#F4623A' },
|
||||
{ key: 'banana', icon: '🍌', name: '香蕉', gi: 'mid', val: 6, color: '#FDD835' },
|
||||
{ key: 'mango', icon: '🥭', name: '芒果', gi: 'mid', val: 6, color: '#FFB300' },
|
||||
{ key: 'pineapple', icon: '🍍', name: '菠萝', gi: 'mid', val: 6, color: '#FBC02D' },
|
||||
{ key: 'kiwi', icon: '🥝', name: '奇异果', gi: 'mid', val: 4, color: '#9CCC65' },
|
||||
{ key: 'melon', icon: '🍈', name: '哈密瓜', gi: 'mid', val: 6, color: '#AED581' },
|
||||
{ key: 'honey', icon: '🍯', name: '蜂蜜', gi: 'mid', val: 6, color: '#F9A825' },
|
||||
{ key: 'wine', icon: '🍷', name: '红酒', gi: 'mid', val: 4, color: '#9B2C3B' },
|
||||
{ key: 'beer', icon: '🍺', name: '啤酒', gi: 'mid', val: 5, color: '#E0A83E' },
|
||||
{ key: 'coffee', icon: '☕', name: '咖啡', gi: 'mid', val: 2, color: '#6F4E37' },
|
||||
{ key: 'blackCoffee', icon: '☕', name: '黑咖啡', gi: 'low', val: -1, color: '#4E342E', img: `${GAMES_IMG_BASE}/%E9%BB%91%E5%92%96%E5%95%A1.png` },
|
||||
{ key: 'wonton', icon: '🥟', name: '馄饨', gi: 'mid', val: 5, color: '#E8DCC8', img: `${GAMES_IMG_BASE}/%E9%A6%84%E9%A5%A8.png` },
|
||||
{ key: 'milletCongee', icon: '🥣', name: '小米粥', gi: 'mid', val: 5, color: '#F5E6B8', img: `${GAMES_IMG_BASE}/%E5%B0%8F%E7%B1%B3%E7%B2%A5.png` },
|
||||
{ key: 'shandongPancake', icon: '🥞', name: '山东煎饼', gi: 'high', val: 16, color: '#D4A574', img: `${GAMES_IMG_BASE}/%E5%B1%B1%E4%B8%9C%E7%85%8E%E9%A5%BC.png` },
|
||||
{ key: 'corn', icon: '🌽', name: '玉米', gi: 'mid', val: 5, color: '#F9C513' },
|
||||
{ key: 'udon', icon: '🍲', name: '乌冬面', gi: 'mid', val: 5, color: '#D9B88F' },
|
||||
{ key: 'milkOats', icon: '🥣', name: '奶冲麦片', gi: 'mid', val: 6, color: '#E8D9B5', img: `${GAMES_IMG_BASE}/%E5%A5%B6%E5%86%B2%E9%BA%A6%E7%89%87.png` },
|
||||
{ key: 'grainMantou', icon: '🫓', name: '杂粮馒头', gi: 'mid', val: 5, color: '#C8A878', img: `${GAMES_IMG_BASE}/%E6%9D%82%E7%B2%AE%E9%A6%92%E5%A4%B4.png` },
|
||||
|
||||
// 高 GI
|
||||
{ key: 'whiteRice', icon: '🍚', name: '白米饭', gi: 'high', val: 18, color: '#E0E0E0', img: `${GAMES_IMG_BASE}/%E7%B1%B3%E9%A5%AD.png` },
|
||||
{ key: 'whiteBread', icon: '🍞', name: '白面包', gi: 'high', val: 16, color: '#D9A85C' },
|
||||
{ key: 'mantou', icon: '🥯', name: '馒头', gi: 'high', val: 17, color: '#ECE0C8' },
|
||||
{ key: 'youtiao', icon: '🥖', name: '油条', gi: 'high', val: 20, color: '#D4943F', img: `${GAMES_IMG_BASE}/%E6%B2%B9%E6%9D%A1.png` },
|
||||
{ key: 'sandwich', icon: '🥪', name: '三明治', gi: 'high', val: 16, color: '#D9B88F', img: `${GAMES_IMG_BASE}/%E4%B8%89%E6%98%8E%E6%B2%BB.png` },
|
||||
{ key: 'biscuit', icon: '🍪', name: '饼干', gi: 'high', val: 18, color: '#C9A063', img: `${GAMES_IMG_BASE}/%E9%A5%BC%E5%B9%B2.png` },
|
||||
{ key: 'cake', icon: '🎂', name: '蛋糕', gi: 'high', val: 22, color: '#F48FB1', img: `${GAMES_IMG_BASE}/%E8%9B%8B%E7%B3%95.png` },
|
||||
{ key: 'eightTreasureCongee', icon: '🥣', name: '八宝粥', gi: 'high', val: 17, color: '#E8C4A0', img: `${GAMES_IMG_BASE}/%E5%85%AB%E5%AE%9D%E7%B2%A5.png` },
|
||||
{ key: 'donut', icon: '🍩', name: '甜甜圈', gi: 'high', val: 22, color: '#E87FB0' },
|
||||
{ key: 'popcorn', icon: '🍿', name: '爆米花', gi: 'high', val: 18, color: '#F5E6B3' },
|
||||
{ key: 'ramen', icon: '🍜', name: '拉面', gi: 'high', val: 17, color: '#E0A96D' },
|
||||
{ key: 'pumpkin', icon: '🎃', name: '南瓜', gi: 'high', val: 15, color: '#EF6C00' },
|
||||
{ key: 'bakedPotato', icon: '🍟', name: '焗薯', gi: 'high', val: 20, color: '#F2C14E' },
|
||||
{ key: 'watermelon', icon: '🍉', name: '西瓜', gi: 'high', val: 16, color: '#F0506A' },
|
||||
{ key: 'sugar', icon: '🍭', name: '砂糖', gi: 'high', val: 25, color: '#FF79B0' },
|
||||
{ key: 'maltose', icon: '🍬', name: '麦芽糖', gi: 'high', val: 24, color: '#FFA726' },
|
||||
{ key: 'soda', icon: '🥤', name: '汽水', gi: 'high', val: 25, color: '#C62828' },
|
||||
{ key: 'orangeJuice', icon: '🧃', name: '柳橙汁', gi: 'high', val: 22, color: '#FF9F1C' },
|
||||
{ key: 'stickyRice', icon: '🍡', name: '糯米饭', gi: 'high', val: 19, color: '#F2A7B8' },
|
||||
{ key: 'riceNoodleSoup', icon: '🍜', name: '汤粉', gi: 'high', val: 16, color: '#E8C9A0', img: `${GAMES_IMG_BASE}/%E6%B1%A4%E7%B2%89.png` },
|
||||
{ key: 'friedNoodles', icon: '🍝', name: '炒面', gi: 'high', val: 18, color: '#C8843C', img: `${GAMES_IMG_BASE}/%E7%82%92%E9%9D%A2.png` },
|
||||
{ key: 'centuryEggCongee', icon: '🥣', name: '皮蛋瘦肉粥', gi: 'high', val: 15, color: '#D9CBB0', img: `${GAMES_IMG_BASE}/%E7%9A%AE%E8%9B%8B%E7%98%A6%E8%82%89%E7%B2%A5.png` }
|
||||
]
|
||||
|
||||
export const FOOD_LIBRARY = RAW_FOODS.map((f, i) => {
|
||||
const eatVal = getEatVal(f)
|
||||
const clearVal = getClearVal({ ...f, eatVal })
|
||||
return {
|
||||
id: i + 1,
|
||||
...f,
|
||||
eatVal,
|
||||
clearVal,
|
||||
giLabel: GI_LABEL[f.gi],
|
||||
get img() {
|
||||
// 优先使用自定义图片素材,缺省回退到 emoji PNG
|
||||
return f.img || getFoodImgUrl(f.icon)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 按 key 查找食材(目标栏等用) */
|
||||
export function getFoodByKey(key) {
|
||||
return FOOD_LIBRARY.find((f) => f.key === key) || null
|
||||
}
|
||||
|
||||
/** 为棋盘 tile 使用的 plain 对象(img 预计算,避免 getter 在响应式里反复触发) */
|
||||
export function cloneFoodType(food) {
|
||||
return {
|
||||
id: food.id,
|
||||
key: food.key,
|
||||
icon: food.icon,
|
||||
name: food.name,
|
||||
gi: food.gi,
|
||||
giLabel: food.giLabel,
|
||||
val: food.val,
|
||||
color: food.color,
|
||||
img: food.img || getFoodImgUrl(food.icon)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 关卡配置:面向 45+ 的认知练习,节奏放慢、无失败惩罚。
|
||||
* 每组目标只比上一组略增,步数充裕(仅展示,不会因耗尽判负)。
|
||||
*/
|
||||
/**
|
||||
* 指定关卡固定食材(按 key)。未配置的关卡走随机抽取逻辑。
|
||||
* 第一关固定为这 16 种带真实图片素材的食物。
|
||||
*/
|
||||
const FIXED_LEVEL_FOODS = {
|
||||
1: [
|
||||
'sandwich',
|
||||
'eightTreasureCongee',
|
||||
'milkOats',
|
||||
'milletCongee',
|
||||
'shandongPancake',
|
||||
'grainMantou',
|
||||
'riceNoodleSoup',
|
||||
'youtiao',
|
||||
'friedNoodles',
|
||||
'sweetPotato',
|
||||
'centuryEggCongee',
|
||||
'whiteRice',
|
||||
'cake',
|
||||
'biscuit',
|
||||
'wonton',
|
||||
'blackCoffee'
|
||||
]
|
||||
}
|
||||
|
||||
export function getLevelConfig(levelId) {
|
||||
const lv = Math.max(1, Math.min(999, Number(levelId) || 1))
|
||||
const tier = Math.floor((lv - 1) / 5)
|
||||
|
||||
return {
|
||||
id: lv,
|
||||
// 步数充裕:仅作展示,认全目标即可通关
|
||||
moves: Math.max(40, 60 - tier * 2),
|
||||
// 认识目标平缓增长,避免给老人压力
|
||||
goalTargets: [6 + tier, 8 + tier],
|
||||
scoreTarget: 2000 + lv * 300,
|
||||
startGlucose: 50,
|
||||
// 固定食材(可选):存在时本关只用这些食物
|
||||
foods: FIXED_LEVEL_FOODS[lv] || null,
|
||||
boosters: {
|
||||
insulin: 3,
|
||||
fiber: 1,
|
||||
meal: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据本局表现计算 1~3 星:完成即得星,步数越省星越多(不依赖血糖) */
|
||||
export function calcLevelStars({ movesLeft, levelConfig }) {
|
||||
const totalMoves = levelConfig?.moves || 1
|
||||
const ratio = totalMoves > 0 ? movesLeft / totalMoves : 0
|
||||
let stars = 1
|
||||
if (ratio >= 0.3) stars = 2
|
||||
if (ratio >= 0.55) stars = 3
|
||||
return stars
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# 识糖小课堂平台接入说明
|
||||
|
||||
## 已接入能力
|
||||
|
||||
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
|
||||
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
|
||||
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||
- 待同步成绩最多本地保留 8 局,重新联网后自动补传。
|
||||
- 微信好友与朋友圈分享使用随机分享码,不在链接中暴露用户 ID。
|
||||
- 每张周分享卡对同一受邀人只记录一次轻量访问,不自动建立家庭或好友绑定。
|
||||
|
||||
## 接口
|
||||
|
||||
- `GET /api/tcm/gameWeeklyLeaderboard`:获取或创建当前周同行榜。
|
||||
- `POST /api/tcm/gameSubmitProgress`:上报 `session_key`、`learned_count`、`score`、`ended`。
|
||||
- `POST /api/tcm/gameRecordShare`:记录分享动作并获取本周分享码。
|
||||
- `POST /api/tcm/gameAcceptShare`:受邀用户登录后提交 `invite_code`。
|
||||
|
||||
四个接口都使用现有 `LoginMiddleware` 校验主小程序 `token`。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
1. 执行 `server/sql/1.9.20260717/add_tcm_endless_game_platform.sql`。
|
||||
2. 发布 `server/app/api/logic/tcm/GamePlatformLogic.php` 和 `TcmController.php`。
|
||||
3. 重新构建并上传小程序前端。
|
||||
|
||||
如果数据库已经执行过本功能的旧版建表脚本,再执行一次
|
||||
`server/sql/1.9.20260717/upgrade_tcm_endless_game_platform_20260717.sql`,用于补充分组锁并把分享去重范围修正为“每张周分享卡”。
|
||||
|
||||
如果后端或数据表尚未发布,游戏仍可离线游玩,榜单会显示“离线记录中”;联网且接口可用后自动补传。
|
||||
|
||||
## 上线前检查
|
||||
|
||||
- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
|
||||
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||
- 周一验证新周重新分组,旧周成绩不带入新周。
|
||||
@@ -1,11 +1,13 @@
|
||||
# 控糖消消乐独立功能包
|
||||
# 识糖小课堂独立功能包
|
||||
|
||||
此目录包含“控糖消消乐”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
|
||||
此目录包含“识糖小课堂”无尽三消版的页面与运行代码。食品图片由远端 COS 提供,图标组件复用 `tongji` 分包的公共组件。
|
||||
|
||||
## 目录内容
|
||||
|
||||
- `index.vue`:页面、棋盘算法、关卡主题、任务、道具、三/四/五连奖励和适老化交互。
|
||||
- `game-endless.scss`:完整页面与动画样式。
|
||||
- `composables/useGameAuth.js`:复用主小程序账号并处理 token 过期重登。
|
||||
- `composables/useGamePlatform.js`:周榜、成绩补传与微信分享的平台连接层。
|
||||
- `composables/useGameSfx.js`:滑动、掉落、消除、连击和大奖音效。
|
||||
|
||||
## 迁移步骤
|
||||
@@ -18,9 +20,11 @@
|
||||
"path": "endless-game/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "控糖消消乐",
|
||||
"navigationBarTitleText": "识糖小课堂",
|
||||
"backgroundColor": "#eefbf4",
|
||||
"disableScroll": false
|
||||
"disableScroll": false,
|
||||
"enableShareAppMessage": true,
|
||||
"enableShareTimeline": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -30,10 +34,10 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前项目另有 `/tongji/pages/game` 页面,不属于本独立功能包,迁移时无需复制。
|
||||
- 旧版“糖分突袭”页面及其独占依赖已经移除,项目只注册 `/tongji/endless-game/index` 这一款小游戏。
|
||||
- 食品图片固定使用 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/games/food/` 前缀,文件名必须与 `FOODS` 配置 key 的大小写保持一致。
|
||||
- 连消小目标按“连消×2”累计 2 次;连续 6 次普通消除没有连消时,下一次掉落会提供连消机会。“连消×3”保留为额外积分、驼乳粉与撒花惊喜,不阻挡主线任务。
|
||||
- 横向 3 个与竖向 3 个相交形成 L/T 形五消时,会在交点生成带高对比 L 标记的范围爆破棋子;该棋子再次被消除时清除周围九格。
|
||||
- 进入游戏时展示“本周 7 人同行榜”界面原型,包含前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。当前榜单明确标注为预览数据,正式用户关系和周榜成绩需要后端接口接入。
|
||||
- 进入游戏时展示“本周 7 人同行榜”,包含真实平台昵称、前后名次、差距提示和可切换的同行/亲友鼓励;顶部“本周同行”可再次打开。后端部署方式见 `PLATFORM_INTEGRATION.md`。
|
||||
- 本功能包依赖 uni-app Vue 3、`@dcloudio/uni-app` 以及 `tongji` 分包内的公共 `TongjiIcon` 组件。
|
||||
- 食物风险等级和科普文案集中在 `index.vue` 的 `FOODS` 配置中,正式上线前仍需由医院医生审核。
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
|
||||
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
|
||||
|
||||
const ROWS = 5
|
||||
const COLS = 4
|
||||
const HIGH_CAP = 3
|
||||
const MAX_CASCADE_WAVES = 3
|
||||
const DANGER_DROP_CHANCE = 0.1
|
||||
const COLUMN_GROUP_BIAS = 0.72
|
||||
|
||||
const FOOD_GROUPS = [
|
||||
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
|
||||
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
|
||||
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
|
||||
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
|
||||
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
|
||||
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
|
||||
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
|
||||
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
|
||||
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
|
||||
]
|
||||
|
||||
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
|
||||
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
|
||||
|
||||
function readNumberArg(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index < 0) return fallback
|
||||
const value = Number(process.argv[index + 1])
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
||||
}
|
||||
|
||||
const RUNS = readNumberArg('--runs', 10000)
|
||||
const TURNS = readNumberArg('--turns', 80)
|
||||
const BASE_SEED = readNumberArg('--seed', 20260722)
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0 || 1
|
||||
return () => {
|
||||
state ^= state << 13
|
||||
state ^= state >>> 17
|
||||
state ^= state << 5
|
||||
return (state >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function cloneBoard(source) {
|
||||
return source.map(tile => tile ? { ...tile } : null)
|
||||
}
|
||||
|
||||
function tile(key) {
|
||||
return { key }
|
||||
}
|
||||
|
||||
function isAdjacent(a, b) {
|
||||
const ar = Math.floor(a / COLS)
|
||||
const ac = a % COLS
|
||||
const br = Math.floor(b / COLS)
|
||||
const bc = b % COLS
|
||||
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
|
||||
}
|
||||
|
||||
function swap(source, a, b) {
|
||||
const next = cloneBoard(source)
|
||||
;[next[a], next[b]] = [next[b], next[a]]
|
||||
return next
|
||||
}
|
||||
|
||||
function shuffle(source, rng) {
|
||||
const result = cloneBoard(source)
|
||||
for (let i = result.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function findMatches(source) {
|
||||
const groups = []
|
||||
for (let row = 0; row < ROWS; row += 1) {
|
||||
let start = 0
|
||||
while (start < COLS) {
|
||||
let end = start + 1
|
||||
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
|
||||
if (source[row * COLS + start] && end - start >= 3) {
|
||||
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
let start = 0
|
||||
while (start < ROWS) {
|
||||
let end = start + 1
|
||||
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
|
||||
if (source[start * COLS + col] && end - start >= 3) {
|
||||
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function enumerateMatchingMoves(source) {
|
||||
const moves = []
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const candidates = []
|
||||
if (i % COLS < COLS - 1) candidates.push(i + 1)
|
||||
if (i + COLS < source.length) candidates.push(i + COLS)
|
||||
for (const j of candidates) {
|
||||
const copy = swap(source, i, j)
|
||||
const groups = findMatches(copy)
|
||||
if (!groups.length) continue
|
||||
const indices = new Set(groups.flatMap(group => group.indices))
|
||||
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
|
||||
moves.push({ from: i, to: j, hitsHigh, groups })
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
function safeMoves(source) {
|
||||
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
|
||||
}
|
||||
|
||||
function wouldCreateMatchAt(source, index, key) {
|
||||
const row = Math.floor(index / COLS)
|
||||
const col = index % COLS
|
||||
const keyAt = target => target === index ? key : source[target]?.key
|
||||
let horizontal = 1
|
||||
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
|
||||
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
|
||||
if (horizontal >= 3) return true
|
||||
let vertical = 1
|
||||
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
|
||||
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
|
||||
return vertical >= 3
|
||||
}
|
||||
|
||||
function openingKeys(state) {
|
||||
const [a, b, c, d] = state.activeSafeKeys
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
return [
|
||||
a, b, c, d,
|
||||
b, c, d, a,
|
||||
c, d, a, danger,
|
||||
d, a, danger, b,
|
||||
a, b, c, d
|
||||
]
|
||||
}
|
||||
|
||||
function forcedPlayableKeys(state) {
|
||||
const [a, b, c, d] = state.activeSafeKeys
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
return [
|
||||
d, a, danger, c,
|
||||
d, c, b, danger,
|
||||
c, a, b, a,
|
||||
d, d, a, a,
|
||||
b, d, c, b
|
||||
]
|
||||
}
|
||||
|
||||
function buildPlayableBoard(state, keys = openingKeys(state)) {
|
||||
let candidate = keys.map(tile)
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||
candidate = shuffle(candidate, state.rng)
|
||||
}
|
||||
state.metrics.guaranteedFallbacks += 1
|
||||
return forcedPlayableKeys(state).map(tile)
|
||||
}
|
||||
|
||||
function makeStablePlayableBoard(state, source) {
|
||||
const tiles = source.filter(Boolean).map(item => ({ ...item }))
|
||||
if (tiles.length === ROWS * COLS) {
|
||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||
const candidate = shuffle(tiles, state.rng)
|
||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||
}
|
||||
}
|
||||
state.metrics.stableFallbacks += 1
|
||||
return buildPlayableBoard(state)
|
||||
}
|
||||
|
||||
function queueSafeGroupTransition(state, keys) {
|
||||
const target = [...keys]
|
||||
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
|
||||
if (!last || !last.every((key, index) => key === target[index])) {
|
||||
state.queuedSafeGroups.push(target)
|
||||
}
|
||||
startNextTransition(state)
|
||||
}
|
||||
|
||||
function startNextTransition(state) {
|
||||
if (state.transition) return false
|
||||
while (state.queuedSafeGroups.length) {
|
||||
const target = state.queuedSafeGroups[0]
|
||||
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
|
||||
if (slot < 0) {
|
||||
state.queuedSafeGroups.shift()
|
||||
continue
|
||||
}
|
||||
const from = state.activeSafeKeys[slot]
|
||||
const to = target[slot]
|
||||
state.activeSafeKeys[slot] = to
|
||||
state.transition = { from, to, slot, age: 0 }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function finishTransitionIfReady(state) {
|
||||
let changed = false
|
||||
while (state.transition) {
|
||||
const { from, to } = state.transition
|
||||
const remaining = state.board.filter(item => item?.key === from).length
|
||||
if (remaining > 2) break
|
||||
if (remaining) {
|
||||
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
|
||||
state.metrics.tailConversions += remaining
|
||||
}
|
||||
state.transition = null
|
||||
changed = true
|
||||
startNextTransition(state)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function prepareTransitionForReshuffle(state) {
|
||||
const activeSet = new Set(state.activeSafeKeys)
|
||||
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
|
||||
state.board.forEach(item => {
|
||||
if (item && activeSet.has(item.key)) counts[item.key] += 1
|
||||
})
|
||||
let imported = 0
|
||||
const next = state.board.map(item => {
|
||||
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
|
||||
if (state.transition && item.key === state.transition.from) {
|
||||
imported += 1
|
||||
counts[state.transition.to] += 1
|
||||
return { ...item, key: state.transition.to }
|
||||
}
|
||||
if (activeSet.has(item.key)) return { ...item }
|
||||
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
|
||||
counts[key] += 1
|
||||
imported += 1
|
||||
return { ...item, key }
|
||||
})
|
||||
state.board = next
|
||||
state.metrics.reshuffleImports += imported
|
||||
}
|
||||
|
||||
function randomFoodKey(state, snapshot, col) {
|
||||
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
|
||||
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
|
||||
return FOOD_GROUPS[state.themeIndex].danger
|
||||
}
|
||||
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
|
||||
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
|
||||
}
|
||||
|
||||
function pickDropKey(state, snapshot, index, col) {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
const key = randomFoodKey(state, snapshot, col)
|
||||
if (!wouldCreateMatchAt(snapshot, index, key)) return key
|
||||
}
|
||||
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
|
||||
.find(key => !wouldCreateMatchAt(snapshot, index, key))
|
||||
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
|
||||
}
|
||||
|
||||
function collapseAndFill(state) {
|
||||
const next = Array(ROWS * COLS).fill(null)
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
const existing = []
|
||||
for (let row = ROWS - 1; row >= 0; row -= 1) {
|
||||
const item = state.board[row * COLS + col]
|
||||
if (item) existing.push(item)
|
||||
}
|
||||
existing.forEach((item, offset) => {
|
||||
next[(ROWS - 1 - offset) * COLS + col] = item
|
||||
})
|
||||
}
|
||||
for (let row = 0; row < ROWS; row += 1) {
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
const index = row * COLS + col
|
||||
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
|
||||
}
|
||||
}
|
||||
state.board = next
|
||||
}
|
||||
|
||||
function reshuffle(state) {
|
||||
prepareTransitionForReshuffle(state)
|
||||
let candidate = null
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
const shuffled = shuffle(state.board, state.rng)
|
||||
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
|
||||
candidate = shuffled
|
||||
break
|
||||
}
|
||||
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
|
||||
finishTransitionIfReady(state)
|
||||
state.metrics.reshuffles += 1
|
||||
}
|
||||
|
||||
function advanceTheme(state) {
|
||||
const previous = FOOD_GROUPS[state.themeIndex]
|
||||
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
|
||||
const next = FOOD_GROUPS[state.themeIndex]
|
||||
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
|
||||
queueSafeGroupTransition(state, next.safe)
|
||||
state.metrics.themeChanges += 1
|
||||
}
|
||||
|
||||
function resolveMatches(state, initialGroups) {
|
||||
let groups = initialGroups
|
||||
let wave = 0
|
||||
let camelMatch = 0
|
||||
while (groups.length && wave < MAX_CASCADE_WAVES) {
|
||||
wave += 1
|
||||
const remove = new Set(groups.flatMap(group => group.indices))
|
||||
for (const group of groups) {
|
||||
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
|
||||
camelMatch = Math.max(camelMatch, group.indices.length)
|
||||
}
|
||||
}
|
||||
remove.forEach(index => { state.board[index] = null })
|
||||
collapseAndFill(state)
|
||||
finishTransitionIfReady(state)
|
||||
groups = findMatches(state.board)
|
||||
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
|
||||
if (autoHigh) {
|
||||
state.metrics.automaticHighAvoided += 1
|
||||
state.board = makeStablePlayableBoard(state, state.board)
|
||||
groups = []
|
||||
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
|
||||
state.metrics.cascadeCaps += 1
|
||||
state.board = makeStablePlayableBoard(state, state.board)
|
||||
groups = []
|
||||
}
|
||||
}
|
||||
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
|
||||
if (camelMatch >= 3) advanceTheme(state)
|
||||
}
|
||||
|
||||
function useCamelTool(state) {
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
const targets = state.board
|
||||
.map((item, index) => item?.key === danger ? index : -1)
|
||||
.filter(index => index >= 0)
|
||||
if (!targets.length) return false
|
||||
const index = targets[Math.floor(state.rng() * targets.length)]
|
||||
state.board[index] = tile('camel')
|
||||
state.metrics.toolUses += 1
|
||||
const groups = findMatches(state.board)
|
||||
if (groups.length) resolveMatches(state, groups)
|
||||
return true
|
||||
}
|
||||
|
||||
function recordFailure(state, type, detail = '') {
|
||||
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
|
||||
if (state.metrics.samples.length < 20) {
|
||||
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
|
||||
}
|
||||
}
|
||||
|
||||
function validateState(state, expectStable = true) {
|
||||
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
|
||||
recordFailure(state, 'invalid_board_size')
|
||||
return false
|
||||
}
|
||||
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
|
||||
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
|
||||
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
|
||||
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
|
||||
|
||||
const allowed = new Set([...state.activeSafeKeys, 'camel'])
|
||||
if (state.transition) allowed.add(state.transition.from)
|
||||
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
|
||||
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
|
||||
|
||||
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
|
||||
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
|
||||
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
|
||||
if (state.transition) {
|
||||
const remaining = state.board.filter(item => item.key === state.transition.from).length
|
||||
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
|
||||
}
|
||||
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
|
||||
return true
|
||||
}
|
||||
|
||||
function createMetrics() {
|
||||
return {
|
||||
runs: RUNS,
|
||||
turnsPerRun: TURNS,
|
||||
totalTurns: 0,
|
||||
validMoves: 0,
|
||||
reshuffles: 0,
|
||||
reshuffleImports: 0,
|
||||
guaranteedFallbacks: 0,
|
||||
stableFallbacks: 0,
|
||||
themeChanges: 0,
|
||||
toolUses: 0,
|
||||
tailConversions: 0,
|
||||
automaticHighAvoided: 0,
|
||||
cascadeCaps: 0,
|
||||
maxCascade: 0,
|
||||
unsafeHighMoveProbes: 0,
|
||||
maxTransitionAge: 0,
|
||||
maxQueuedThemes: 0,
|
||||
transitionDrainTurns: 0,
|
||||
unfinishedTransitions: 0,
|
||||
failures: {},
|
||||
samples: []
|
||||
}
|
||||
}
|
||||
|
||||
function runTargetedTailTests(metrics) {
|
||||
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
|
||||
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
|
||||
const previousGroup = FOOD_GROUPS[groupIndex]
|
||||
const nextGroup = FOOD_GROUPS[nextIndex]
|
||||
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
|
||||
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
|
||||
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
|
||||
if (slot < 0) continue
|
||||
for (const remaining of [0, 1, 2]) {
|
||||
const state = {
|
||||
run: -1,
|
||||
turn: remaining,
|
||||
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
|
||||
metrics,
|
||||
themeIndex: nextIndex,
|
||||
activeSafeKeys: [...nextGroup.safe],
|
||||
transition: {
|
||||
from: previousGroup.safe[slot],
|
||||
to: nextGroup.safe[slot],
|
||||
slot,
|
||||
age: 0
|
||||
},
|
||||
queuedSafeGroups: [[...nextGroup.safe]],
|
||||
board: []
|
||||
}
|
||||
const fill = nextGroup.safe
|
||||
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
|
||||
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
|
||||
finishTransitionIfReady(state)
|
||||
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
|
||||
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = createMetrics()
|
||||
runTargetedTailTests(metrics)
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const state = {
|
||||
run,
|
||||
turn: 0,
|
||||
rng: createRng(BASE_SEED + run * 2654435761),
|
||||
metrics,
|
||||
themeIndex: run % FOOD_GROUPS.length,
|
||||
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
|
||||
transition: null,
|
||||
queuedSafeGroups: [],
|
||||
board: []
|
||||
}
|
||||
state.board = buildPlayableBoard(state)
|
||||
validateState(state)
|
||||
|
||||
let toolsUsed = 0
|
||||
for (let turn = 0; turn < TURNS; turn += 1) {
|
||||
state.turn = turn
|
||||
metrics.totalTurns += 1
|
||||
if (state.transition) {
|
||||
state.transition.age += 1
|
||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||
}
|
||||
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
|
||||
|
||||
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
|
||||
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
|
||||
toolsUsed += 1
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
}
|
||||
|
||||
let moves = safeMoves(state.board)
|
||||
if (!moves.length) {
|
||||
reshuffle(state)
|
||||
moves = safeMoves(state.board)
|
||||
}
|
||||
if (!moves.length) {
|
||||
recordFailure(state, 'dead_board_after_reshuffle')
|
||||
break
|
||||
}
|
||||
|
||||
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
|
||||
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
|
||||
if (unsafe) {
|
||||
const probe = swap(state.board, unsafe.from, unsafe.to)
|
||||
const direct = findMatches(probe)
|
||||
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
|
||||
recordFailure(state, 'unsafe_high_probe_missed')
|
||||
}
|
||||
metrics.unsafeHighMoveProbes += 1
|
||||
}
|
||||
|
||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||
state.board = swap(state.board, move.from, move.to)
|
||||
const groups = findMatches(state.board)
|
||||
if (!groups.length) {
|
||||
recordFailure(state, 'safe_move_without_match')
|
||||
break
|
||||
}
|
||||
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
|
||||
recordFailure(state, 'safe_move_hit_high')
|
||||
break
|
||||
}
|
||||
resolveMatches(state, groups)
|
||||
metrics.validMoves += 1
|
||||
|
||||
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
|
||||
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
if (!safeMoves(state.board).length) reshuffle(state)
|
||||
validateState(state)
|
||||
}
|
||||
|
||||
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
|
||||
let drainTurn = 0
|
||||
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
|
||||
state.turn = TURNS + drainTurn
|
||||
if (state.transition) {
|
||||
state.transition.age += 1
|
||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||
}
|
||||
let moves = safeMoves(state.board)
|
||||
if (!moves.length) {
|
||||
reshuffle(state)
|
||||
moves = safeMoves(state.board)
|
||||
}
|
||||
if (!moves.length) {
|
||||
recordFailure(state, 'dead_board_while_draining')
|
||||
break
|
||||
}
|
||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||
state.board = swap(state.board, move.from, move.to)
|
||||
resolveMatches(state, findMatches(state.board))
|
||||
metrics.validMoves += 1
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
if (!safeMoves(state.board).length) reshuffle(state)
|
||||
validateState(state)
|
||||
drainTurn += 1
|
||||
}
|
||||
metrics.transitionDrainTurns += drainTurn
|
||||
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
|
||||
}
|
||||
|
||||
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
|
||||
const result = {
|
||||
ok: failureCount === 0,
|
||||
...metrics,
|
||||
failureCount
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||
if (!result.ok) process.exitCode = 1
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const FOOD_ICON_FILES = [
|
||||
'tofu.jpg',
|
||||
'spinach.jpg',
|
||||
'carrot.jpg',
|
||||
'fish.jpg',
|
||||
'redBeanCongee.jpg',
|
||||
'milkTea.jpg'
|
||||
]
|
||||
|
||||
/**
|
||||
* Keep endless-game food icons inside the tongji subpackage.
|
||||
* Importing them from Vue would make Vite emit them into the main-package assets folder.
|
||||
*/
|
||||
export function tongjiFoodAssetsPlugin(): Plugin {
|
||||
return {
|
||||
name: 'tongji-food-assets',
|
||||
apply: 'build',
|
||||
generateBundle() {
|
||||
FOOD_ICON_FILES.forEach((fileName) => {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `tongji/endless-game/assets/food/${fileName}`,
|
||||
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 识糖小课堂独立登录适配层。复用主小程序账号,但不依赖 tongji 其他页面代码。
|
||||
*/
|
||||
export function useGameAuth(proxy) {
|
||||
let loginPromise = null
|
||||
|
||||
function hasToken() {
|
||||
return !!String(uni.getStorageSync('token') || '').trim()
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
uni.removeStorageSync('token')
|
||||
}
|
||||
|
||||
function wxLoginCode() {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => res?.code ? resolve(res.code) : reject(new Error('微信登录未返回 code')),
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithCode(code) {
|
||||
const res = await proxy.apiUrl({
|
||||
url: '/api/login/mnpLogin',
|
||||
method: 'POST',
|
||||
data: { code }
|
||||
}, false)
|
||||
if (res?.code !== 1 || !res.data?.token) {
|
||||
clearToken()
|
||||
throw new Error(res?.msg || '登录失败')
|
||||
}
|
||||
uni.setStorageSync('token', res.data.token)
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
|
||||
async function verifyOrLogin() {
|
||||
if (hasToken()) {
|
||||
try {
|
||||
const res = await proxy.apiUrl({ url: '/api/user/info', method: 'POST' }, false)
|
||||
if (res?.code === 1 && res.data) {
|
||||
uni.setStorageSync('userData', res.data)
|
||||
return true
|
||||
}
|
||||
// 只有明确的登录失效才重新换取 token;其他业务错误先保留原登录。
|
||||
if (res?.code !== -1) return true
|
||||
} catch (_) {
|
||||
// 断网不清除仍可能有效的 token,成绩由离线队列稍后补传。
|
||||
return true
|
||||
}
|
||||
clearToken()
|
||||
}
|
||||
const code = await wxLoginCode()
|
||||
return loginWithCode(code)
|
||||
}
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
if (loginPromise) return loginPromise
|
||||
loginPromise = verifyOrLogin()
|
||||
.then(ok => !!ok)
|
||||
.catch(() => false)
|
||||
// 只合并同时发生的登录;成功结果不能永久缓存,否则 token 过期后无法恢复。
|
||||
.finally(() => { loginPromise = null })
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
return { ensureLoggedIn }
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const EMPTY_BOARD = {
|
||||
week_start: '',
|
||||
week_end: '',
|
||||
sex_label: '同行',
|
||||
group_size: 7,
|
||||
member_count: 0,
|
||||
players: [],
|
||||
me: { count: 0, rank: 1, best_score: 0, distance: 0, is_first: true },
|
||||
invite_code: ''
|
||||
}
|
||||
const PENDING_SYNC_KEY = 'tongji_endless_pending_sync_v1'
|
||||
|
||||
function createSessionKey() {
|
||||
const random = Math.random().toString(36).slice(2, 12)
|
||||
return `game_${Date.now().toString(36)}_${random}`
|
||||
}
|
||||
|
||||
function readPendingPayloads() {
|
||||
try {
|
||||
const stored = uni.getStorageSync(PENDING_SYNC_KEY)
|
||||
if (!Array.isArray(stored)) return []
|
||||
return stored.filter(item => (
|
||||
item
|
||||
&& /^[A-Za-z0-9_-]{16,64}$/.test(String(item.session_key || ''))
|
||||
&& Number(item.learned_count) >= 0
|
||||
)).slice(-8)
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 小游戏的平台连接层。复用主小程序 token,不在游戏里另建账号。
|
||||
* 每次上报的是本局绝对值,后端按 session_key 去重,断网重试也不会重复加分。
|
||||
*/
|
||||
export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
const connected = ref(false)
|
||||
const loading = ref(false)
|
||||
const syncStatus = ref('idle')
|
||||
const lastError = ref('')
|
||||
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||
const confirmedSessionLearned = ref(0)
|
||||
|
||||
let sessionKey = createSessionKey()
|
||||
let syncTimer = null
|
||||
let syncing = false
|
||||
let queuedPayloads = readPendingPayloads()
|
||||
let connectPromise = null
|
||||
|
||||
function persistPendingPayloads() {
|
||||
try { uni.setStorageSync(PENDING_SYNC_KEY, queuedPayloads.slice(-8)) } catch (_) {}
|
||||
}
|
||||
|
||||
async function api(request) {
|
||||
if (!proxy?.apiUrl) throw new Error('平台接口未初始化')
|
||||
return proxy.apiUrl(request, false)
|
||||
}
|
||||
|
||||
function applyLeaderboard(data, confirmedForSession = '') {
|
||||
if (!data || !Array.isArray(data.players)) return
|
||||
const inactiveSessionResponse = confirmedForSession && confirmedForSession !== sessionKey
|
||||
if (!inactiveSessionResponse) {
|
||||
leaderboard.value = {
|
||||
...EMPTY_BOARD,
|
||||
...data,
|
||||
me: { ...EMPTY_BOARD.me, ...(data.me || {}) },
|
||||
players: data.players
|
||||
}
|
||||
}
|
||||
if (confirmedForSession === sessionKey && data.confirmed_session_learned != null) {
|
||||
confirmedSessionLearned.value = Number(data.confirmed_session_learned) || 0
|
||||
}
|
||||
connected.value = true
|
||||
syncStatus.value = 'synced'
|
||||
lastError.value = ''
|
||||
}
|
||||
|
||||
async function refreshLeaderboard() {
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||
method: 'GET',
|
||||
data: { session_key: sessionKey }
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '同行榜加载失败')
|
||||
}
|
||||
applyLeaderboard(res.data, sessionKey)
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function acceptShare(inviteCode) {
|
||||
const code = String(inviteCode || '').trim().toUpperCase()
|
||||
if (!code) return
|
||||
try {
|
||||
await api({
|
||||
url: '/api/tcm/gameAcceptShare',
|
||||
method: 'POST',
|
||||
data: { invite_code: code }
|
||||
})
|
||||
} catch (_) {
|
||||
// 分享关系是辅助能力,不阻断进入游戏。
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(inviteCode = '') {
|
||||
if (connectPromise) return connectPromise
|
||||
connectPromise = (async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('登录失败')
|
||||
await acceptShare(inviteCode)
|
||||
await refreshLeaderboard()
|
||||
if (queuedPayloads.length) flushProgress()
|
||||
return true
|
||||
} catch (error) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
lastError.value = String(error?.message || '平台连接失败,请点击重试')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
connectPromise = null
|
||||
}
|
||||
})()
|
||||
return connectPromise
|
||||
}
|
||||
|
||||
function beginSession(existingSessionKey = '') {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
const restoredKey = String(existingSessionKey || '').trim()
|
||||
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
|
||||
? restoredKey
|
||||
: createSessionKey()
|
||||
confirmedSessionLearned.value = 0
|
||||
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function getSessionKey() {
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function queueProgress(learnedCount, score, ended = false) {
|
||||
const nextPayload = {
|
||||
session_key: sessionKey,
|
||||
learned_count: Math.max(0, Number(learnedCount) || 0),
|
||||
score: Math.max(0, Number(score) || 0),
|
||||
ended: ended ? 1 : 0
|
||||
}
|
||||
const existingIndex = queuedPayloads.findIndex(item => item.session_key === sessionKey)
|
||||
if (existingIndex >= 0) {
|
||||
const existing = queuedPayloads[existingIndex]
|
||||
queuedPayloads[existingIndex] = {
|
||||
...nextPayload,
|
||||
learned_count: Math.max(existing.learned_count, nextPayload.learned_count),
|
||||
score: Math.max(existing.score, nextPayload.score),
|
||||
ended: Math.max(existing.ended, nextPayload.ended)
|
||||
}
|
||||
} else {
|
||||
queuedPayloads.push(nextPayload)
|
||||
}
|
||||
persistPendingPayloads()
|
||||
syncStatus.value = 'pending'
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
if (ended) {
|
||||
flushProgress()
|
||||
} else {
|
||||
syncTimer = setTimeout(flushProgress, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function flushProgress() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
if (syncing || !queuedPayloads.length) return
|
||||
const payload = queuedPayloads[0]
|
||||
syncing = true
|
||||
syncStatus.value = 'syncing'
|
||||
try {
|
||||
if (!connected.value) {
|
||||
const loggedIn = await ensureLoggedIn()
|
||||
if (!loggedIn) throw new Error('未登录')
|
||||
}
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameSubmitProgress',
|
||||
method: 'POST',
|
||||
data: payload
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '成绩保存失败')
|
||||
}
|
||||
applyLeaderboard(res.data, payload.session_key)
|
||||
// 请求发出后玩家可能又完成了消除。只移除已经被本次请求覆盖的进度,
|
||||
// 不能按 session_key 整局删除,否则会丢失请求进行期间产生的新进度。
|
||||
queuedPayloads = queuedPayloads.filter(item => (
|
||||
item.session_key !== payload.session_key
|
||||
|| Number(item.learned_count) > Number(payload.learned_count)
|
||||
|| Number(item.score) > Number(payload.score)
|
||||
|| Number(item.ended) > Number(payload.ended)
|
||||
))
|
||||
persistPendingPayloads()
|
||||
} catch (error) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
|
||||
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||
persistPendingPayloads()
|
||||
} finally {
|
||||
syncing = false
|
||||
if (queuedPayloads.length && connected.value) {
|
||||
setTimeout(flushProgress, 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAndRefresh(learnedCount, score) {
|
||||
queueProgress(learnedCount, score, false)
|
||||
await flushProgress()
|
||||
if (!connected.value) {
|
||||
await connect()
|
||||
if (queuedPayloads.length) await flushProgress()
|
||||
} else {
|
||||
try { await refreshLeaderboard() } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function recordShare() {
|
||||
try {
|
||||
if (!connected.value && !(await connect())) return
|
||||
const res = await api({ url: '/api/tcm/gameRecordShare', method: 'POST' })
|
||||
if (res?.code === 1 && res.data?.invite_code) {
|
||||
leaderboard.value = { ...leaderboard.value, invite_code: res.data.invite_code }
|
||||
}
|
||||
} catch (_) {
|
||||
// 分享本身仍可进行,统计失败不影响用户。
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
syncTimer = null
|
||||
persistPendingPayloads()
|
||||
}
|
||||
|
||||
return {
|
||||
connected,
|
||||
loading,
|
||||
syncStatus,
|
||||
lastError,
|
||||
leaderboard,
|
||||
confirmedSessionLearned,
|
||||
connect,
|
||||
beginSession,
|
||||
getSessionKey,
|
||||
queueProgress,
|
||||
flushProgress,
|
||||
syncAndRefresh,
|
||||
refreshLeaderboard,
|
||||
recordShare,
|
||||
dispose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 识糖小课堂 3D 食物图标候选稿 v1
|
||||
|
||||
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
|
||||
- 单图尺寸:320 × 320,JPEG。
|
||||
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
|
||||
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
|
||||
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
|
||||
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
|
||||
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
|
||||
|
||||
目录说明:
|
||||
|
||||
- `icons/`:可直接用于游戏的独立图标。
|
||||
- `sheets/`:11 张分组预览母版,方便整体审美检查。
|
||||
- `manifest.json`:食物中文名、代码键名与文件路径清单。
|
||||
|
||||
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": 1,
|
||||
"style": "chinese-rounded-3d-elderly-friendly",
|
||||
"iconSize": {
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"format": "jpg"
|
||||
},
|
||||
"items": [
|
||||
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
|
||||
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
|
||||
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
|
||||
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
|
||||
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
|
||||
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
|
||||
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
|
||||
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
|
||||
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
|
||||
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
|
||||
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
|
||||
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
|
||||
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
|
||||
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
|
||||
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
|
||||
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
|
||||
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
|
||||
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
|
||||
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
|
||||
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
|
||||
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
|
||||
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
|
||||
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
|
||||
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
|
||||
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
|
||||
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
|
||||
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
|
||||
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
|
||||
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
|
||||
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
|
||||
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
|
||||
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
|
||||
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
|
||||
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
|
||||
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
|
||||
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
|
||||
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
|
||||
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
|
||||
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
|
||||
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
|
||||
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
|
||||
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -46,7 +46,44 @@
|
||||
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
||||
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||
|
||||
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||
.eg-title-wrap > .eg-title,
|
||||
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
|
||||
.eg-title-wrap.is-noticing > .eg-title,
|
||||
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
|
||||
.eg-nav-notice {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 5rpx 8rpx;
|
||||
border: 1rpx solid rgba(21,94,75,.09);
|
||||
border-radius: 16rpx;
|
||||
color: rgba(38,86,72,.82);
|
||||
background: rgba(255,255,255,.58);
|
||||
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egNavNotice 1.05s ease-out both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-nav-notice-title,
|
||||
.eg-nav-notice-detail {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
|
||||
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
|
||||
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||
@@ -117,12 +154,20 @@
|
||||
background: rgba(255,255,255,.92);
|
||||
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||
}
|
||||
.eg-board-shell.is-flowing {
|
||||
border-color: rgba(245, 158, 11, .72);
|
||||
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
|
||||
animation: egFlowShell 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
|
||||
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
|
||||
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
|
||||
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||
@@ -167,7 +212,15 @@
|
||||
.eg-cell.is-level-high { background: #fff0e4; }
|
||||
.eg-cell.is-milk { background: #edf7ff; }
|
||||
.eg-cell.is-high { border-color: #ef7b45; }
|
||||
.eg-cell.is-villain {
|
||||
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
|
||||
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
|
||||
}
|
||||
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
|
||||
.eg-cell.is-villain.is-clearing .eg-food-image,
|
||||
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
|
||||
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
|
||||
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||
@@ -326,6 +379,7 @@
|
||||
.eg-weekly-title-line { display: flex; align-items: center; gap: 10rpx; }
|
||||
.eg-weekly-title { color: #155e4b; font-size: 37rpx; font-weight: 1000; }
|
||||
.eg-weekly-preview { padding: 5rpx 10rpx; border-radius: 999rpx; color: #9a5b14; background: #fff0bd; font-size: 19rpx; font-weight: 900; }
|
||||
.eg-weekly-preview.is-offline { color: #69766f; background: #e8eeeb; }
|
||||
.eg-weekly-sub { display: block; margin-top: 5rpx; color: #6b837a; font-size: 23rpx; font-weight: 700; }
|
||||
.eg-weekly-medal { display: flex; width: 76rpx; height: 76rpx; align-items: center; justify-content: center; border: 5rpx solid #fff3b2; border-radius: 50%; color: #fff; background: linear-gradient(145deg, #f5ad24, #e26925); box-shadow: 0 8rpx 0 #b85421, 0 12rpx 24rpx rgba(181,84,33,.24); font-size: 39rpx; font-weight: 1000; }
|
||||
.eg-weekly-progress-card { display: flex; align-items: center; justify-content: space-between; margin-top: 20rpx; padding: 16rpx 18rpx; border: 3rpx solid #d7ebe2; border-radius: 24rpx; background: rgba(255,255,255,.9); }
|
||||
@@ -336,9 +390,13 @@
|
||||
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
|
||||
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
|
||||
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
|
||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||
.eg-weekly-avatar-image { display: block; width: 100%; height: 100%; border-radius: 50%; }
|
||||
.eg-weekly-avatar.is-tone-2 { background: #e39932; }
|
||||
.eg-weekly-avatar.is-tone-3 { background: #6c83c8; }
|
||||
.eg-weekly-avatar.is-tone-4 { background: #e5683e; }
|
||||
@@ -348,6 +406,9 @@
|
||||
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
|
||||
.eg-weekly-connection > text:first-child { flex: 1; }
|
||||
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
|
||||
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||
@@ -358,6 +419,8 @@
|
||||
.eg-cheer-copy { display: block; margin-top: 7rpx; color: #55462d; font-size: 28rpx; font-weight: 900; line-height: 1.42; }
|
||||
.eg-cheer-note { display: block; margin-top: 5rpx; color: #8d7c77; font-size: 18rpx; }
|
||||
.eg-weekly-start { display: flex; height: 84rpx; align-items: center; justify-content: center; margin-top: 18rpx; border-radius: 24rpx; color: #fff; background: linear-gradient(135deg, #24906c, #12654a); box-shadow: 0 9rpx 20rpx rgba(18,101,74,.24); font-size: 30rpx; font-weight: 1000; }
|
||||
.eg-weekly-share { display: flex; width: 100%; height: 76rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #b9d9cc; border-radius: 22rpx; color: #17684f; background: rgba(255,255,255,.82); font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-weekly-share::after { border: 0; }
|
||||
.eg-weekly-footnote { display: block; margin-top: 10rpx; color: #8b8b7d; font-size: 18rpx; text-align: center; }
|
||||
|
||||
.eg-stage-overlay { position: fixed; z-index: 58; inset: 0; display: flex; align-items: center; justify-content: center; padding: 40rpx; background: rgba(22,62,50,.42); pointer-events: none; }
|
||||
@@ -394,6 +457,8 @@
|
||||
.eg-result-best text:last-child { color: #315d4e; font-size: 27rpx; font-weight: 900; }
|
||||
.eg-primary-btn, .eg-secondary-btn { display: flex; height: 88rpx; align-items: center; justify-content: center; border-radius: 24rpx; font-size: 28rpx; font-weight: 800; }
|
||||
.eg-primary-btn { color: #fff; background: linear-gradient(135deg, #238b68, #126649); box-shadow: 0 10rpx 22rpx rgba(18,102,73,.23); }
|
||||
.eg-share-btn { display: flex; width: 100%; height: 80rpx; align-items: center; justify-content: center; margin: 12rpx 0 0; padding: 0; border: 3rpx solid #bddbce; border-radius: 24rpx; color: #17664e; background: #f4fbf7; font-size: 27rpx; font-weight: 900; line-height: 1; }
|
||||
.eg-share-btn::after { border: 0; }
|
||||
.eg-secondary-btn { margin-top: 12rpx; color: #45665b; background: #edf4f1; }
|
||||
@keyframes egPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,.2); }
|
||||
@@ -411,6 +476,13 @@
|
||||
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||
}
|
||||
|
||||
@keyframes egVillainPeek {
|
||||
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
|
||||
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
|
||||
88% { transform: translateY(0) rotate(1.5deg); }
|
||||
95% { transform: translateY(-2rpx) rotate(0); }
|
||||
}
|
||||
|
||||
@keyframes egTaskRewardFlight {
|
||||
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||
@@ -418,6 +490,13 @@
|
||||
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egNavNotice {
|
||||
0% { opacity: 0; transform: translate(-50%, -42%); }
|
||||
12% { opacity: .9; transform: translate(-50%, -50%); }
|
||||
42% { opacity: .82; transform: translate(-50%, -50%); }
|
||||
100% { opacity: 0; transform: translate(-50%, -58%); }
|
||||
}
|
||||
|
||||
@keyframes egRewardGlow {
|
||||
from { opacity: .4; transform: scale(.86); }
|
||||
to { opacity: .9; transform: scale(1.12); }
|
||||
@@ -547,6 +626,16 @@
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFlowShell {
|
||||
from { filter: saturate(1); }
|
||||
to { filter: saturate(1.08); }
|
||||
}
|
||||
|
||||
@keyframes egFlowBadge {
|
||||
from { transform: scale(.98); filter: brightness(.96); }
|
||||
to { transform: scale(1.035); filter: brightness(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egStageIn {
|
||||
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||
|
||||
@@ -99,23 +99,13 @@
|
||||
<text class="st-chart-mock-hint">示例数据,累计记录满 7 天后显示您的真实趋势</text>
|
||||
</view>
|
||||
<view class="st-game-entry-list">
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goSugarRaidGamePage">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="activity" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">认一认:哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
<view class="st-game-entry st-game-entry--in-card" @click="goEndlessGamePage">
|
||||
<view class="st-game-entry-icon">
|
||||
<TongjiIcon name="sparkles" size="sm" color="#006c49" />
|
||||
</view>
|
||||
<view class="st-game-entry-body">
|
||||
<text class="st-game-entry-title">控糖消消乐</text>
|
||||
<text class="st-game-entry-sub">无尽消除,挑战控糖高分</text>
|
||||
<text class="st-game-entry-title">识糖小课堂</text>
|
||||
<text class="st-game-entry-sub">三消认食物,学习哪些食物含糖高</text>
|
||||
</view>
|
||||
<TongjiIcon name="chevron-right" size="sm" color="#64748b" />
|
||||
</view>
|
||||
@@ -1533,10 +1523,6 @@ function openGamePage(url) {
|
||||
})
|
||||
}
|
||||
|
||||
function goSugarRaidGamePage() {
|
||||
openGamePage('/tongji/pages/game')
|
||||
}
|
||||
|
||||
function goEndlessGamePage() {
|
||||
openGamePage('/tongji/endless-game/index')
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import Optimization from '@uni-ku/bundle-optimizer'
|
||||
import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
plugins: [
|
||||
uni(),
|
||||
Optimization({
|
||||
enable: {
|
||||
optimization: true,
|
||||
'async-import': false,
|
||||
'async-component': false,
|
||||
},
|
||||
dts: false,
|
||||
logger: false,
|
||||
}),
|
||||
tongjiFoodAssetsPlugin(),
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
|
||||
@@ -21,6 +21,7 @@ use app\api\logic\tcm\DailyFamilyLikeLogic;
|
||||
use app\api\logic\tcm\DailyShareLogic;
|
||||
use app\api\logic\tcm\DailyPhoneLogic;
|
||||
use app\api\logic\tcm\DailyDietAiLogic;
|
||||
use app\api\logic\tcm\GamePlatformLogic;
|
||||
use app\adminapi\logic\ConfigLogic;
|
||||
use app\common\model\tcm\Diagnosis;
|
||||
use app\common\model\tcm\BloodRecord;
|
||||
@@ -39,6 +40,57 @@ class TcmController extends BaseApiController
|
||||
* @var array
|
||||
*/
|
||||
public array $notNeedLogin = ['getPatientSignature', 'diagnosisDetail', 'getDict', 'confirmDiagnosis', 'getCardList', 'getOrderByNo', 'patientHangupVideo', 'dailySharePreview', 'dailyFamilyLike'];
|
||||
|
||||
/** 获取当前登录用户的控糖消消乐每周7人同行榜。 */
|
||||
public function gameWeeklyLeaderboard()
|
||||
{
|
||||
$result = GamePlatformLogic::leaderboard(
|
||||
(int) $this->userId,
|
||||
(string) $this->request->get('session_key', '')
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 幂等上报一局的认糖进度和分数。 */
|
||||
public function gameSubmitProgress()
|
||||
{
|
||||
$result = GamePlatformLogic::submitProgress((int) $this->userId, [
|
||||
'session_key' => (string) $this->request->post('session_key', ''),
|
||||
'learned_count' => (int) $this->request->post('learned_count', 0),
|
||||
'score' => (int) $this->request->post('score', 0),
|
||||
'ended' => (int) $this->request->post('ended', 0),
|
||||
]);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 记录发起分享,返回本周不包含用户ID的分享码。 */
|
||||
public function gameRecordShare()
|
||||
{
|
||||
$result = GamePlatformLogic::recordShare((int) $this->userId);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/** 登录用户从微信分享卡片进入时,记录一次轻量同行关系。 */
|
||||
public function gameAcceptShare()
|
||||
{
|
||||
$result = GamePlatformLogic::acceptShare(
|
||||
(int) $this->userId,
|
||||
(string) $this->request->post('invite_code', '')
|
||||
);
|
||||
if ($result === false) {
|
||||
return $this->fail(GamePlatformLogic::getError());
|
||||
}
|
||||
return $this->data($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取患者签名(供小程序调用)
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\common\service\FileService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 控糖消消乐平台能力:真实用户、每周7人同行榜、幂等成绩上报和微信分享。
|
||||
* 前期榜单统一混排;sex 字段只保留为用户资料快照,不参与分组。
|
||||
*/
|
||||
class GamePlatformLogic
|
||||
{
|
||||
private const GROUP_SIZE = 7;
|
||||
private const MAX_SESSION_LEARNED = 20000;
|
||||
private const MAX_SCORE = 100000000;
|
||||
|
||||
protected static string $error = '';
|
||||
|
||||
public static function getError(): string
|
||||
{
|
||||
return self::$error;
|
||||
}
|
||||
|
||||
protected static function fail(string $message): bool
|
||||
{
|
||||
self::$error = $message;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户所在的真实周榜;首次进入会分配到当周混合7人组。
|
||||
*/
|
||||
public static function leaderboard(int $userId, string $sessionKey = ''): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
$result = self::buildLeaderboard((int) $score['group_id'], $userId, $inviteCode);
|
||||
$sessionKey = trim($sessionKey);
|
||||
if (preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||||
$confirmed = Db::name('tcm_game_session')
|
||||
->where('session_key', $sessionKey)
|
||||
->where('user_id', $userId)
|
||||
->value('learned_count');
|
||||
$result['confirmed_session_learned'] = max(0, (int) $confirmed);
|
||||
}
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('leaderboard', $e);
|
||||
return self::fail('同行榜暂时不可用,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报每局绝对进度。session_key + learned_count 共同保证网络重试不会重复计分。
|
||||
*
|
||||
* @param array{session_key:string,learned_count:int,score:int,ended:int|bool} $params
|
||||
*/
|
||||
public static function submitProgress(int $userId, array $params): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
|
||||
$sessionKey = trim((string) ($params['session_key'] ?? ''));
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{16,64}$/', $sessionKey)) {
|
||||
return self::fail('游戏局标识无效');
|
||||
}
|
||||
$learned = max(0, min(self::MAX_SESSION_LEARNED, (int) ($params['learned_count'] ?? 0)));
|
||||
$scoreValue = max(0, min(self::MAX_SCORE, (int) ($params['score'] ?? 0)));
|
||||
$ended = !empty($params['ended']) ? 1 : 0;
|
||||
$weekStart = self::weekStart();
|
||||
|
||||
try {
|
||||
Db::startTrans();
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $weekStart);
|
||||
$session = Db::name('tcm_game_session')
|
||||
->where('session_key', $sessionKey)
|
||||
->lock(true)
|
||||
->find();
|
||||
|
||||
$now = time();
|
||||
if ($session && (int) $session['user_id'] !== $userId) {
|
||||
Db::rollback();
|
||||
return self::fail('游戏局标识已被使用');
|
||||
}
|
||||
|
||||
if (!$session) {
|
||||
$sessionId = Db::name('tcm_game_session')->insertGetId([
|
||||
'session_key' => $sessionKey,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$session = [
|
||||
'id' => $sessionId,
|
||||
'week_start' => $weekStart,
|
||||
'learned_count' => 0,
|
||||
'last_score' => 0,
|
||||
'ended' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$sessionWeek = (string) $session['week_start'];
|
||||
if ($sessionWeek !== $weekStart) {
|
||||
$weeklyScore = self::ensureWeeklyScore($userId, $sessionWeek);
|
||||
}
|
||||
|
||||
$confirmedLearned = (int) $session['learned_count'];
|
||||
$nextLearned = max($confirmedLearned, $learned);
|
||||
$delta = $nextLearned - $confirmedLearned;
|
||||
$wasEnded = (int) $session['ended'] === 1;
|
||||
$markEnded = $wasEnded || $ended === 1;
|
||||
|
||||
Db::name('tcm_game_session')->where('id', (int) $session['id'])->update([
|
||||
'learned_count' => $nextLearned,
|
||||
'last_score' => max((int) $session['last_score'], $scoreValue),
|
||||
'ended' => $markEnded ? 1 : 0,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$weeklyLearned = (int) $weeklyScore['learned_count'] + $delta;
|
||||
$weeklyBest = max((int) $weeklyScore['best_score'], $scoreValue);
|
||||
$gamesPlayed = (int) $weeklyScore['games_played'] + (!$wasEnded && $ended === 1 ? 1 : 0);
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $weeklyScore['id'])->update([
|
||||
'learned_count' => $weeklyLearned,
|
||||
'best_score' => $weeklyBest,
|
||||
'games_played' => $gamesPlayed,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
$inviteCode = self::ensureShareInvite($userId, $sessionWeek);
|
||||
Db::commit();
|
||||
|
||||
$result = self::buildLeaderboard((int) $weeklyScore['group_id'], $userId, $inviteCode, $sessionWeek);
|
||||
$result['confirmed_session_learned'] = $nextLearned;
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('submitProgress', $e);
|
||||
return self::fail('成绩保存失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/** 记录用户发起一次微信分享,并返回当前分享码。 */
|
||||
public static function recordShare(int $userId): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
try {
|
||||
Db::startTrans();
|
||||
$score = self::ensureWeeklyScore($userId, self::weekStart());
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $score['id'])->inc('share_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
$inviteCode = self::ensureShareInvite($userId, self::weekStart());
|
||||
Db::commit();
|
||||
return ['invite_code' => $inviteCode];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('recordShare', $e);
|
||||
return self::fail('分享记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录从分享卡片进入。仅记录一次轻量同行关系,不做家庭/好友强绑定。
|
||||
*/
|
||||
public static function acceptShare(int $userId, string $inviteCode): array|false
|
||||
{
|
||||
self::$error = '';
|
||||
$inviteCode = strtoupper(trim($inviteCode));
|
||||
if ($userId <= 0) {
|
||||
return self::fail('请先登录');
|
||||
}
|
||||
if (!preg_match('/^[A-F0-9]{12}$/', $inviteCode)) {
|
||||
return self::fail('分享码无效');
|
||||
}
|
||||
|
||||
try {
|
||||
$invite = Db::name('tcm_game_share_invite')->where('invite_code', $inviteCode)->find();
|
||||
if (!$invite) {
|
||||
return self::fail('分享已失效');
|
||||
}
|
||||
$inviterUserId = (int) $invite['user_id'];
|
||||
if ($inviterUserId === $userId) {
|
||||
return ['accepted' => false, 'message' => '这是您自己的分享'];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
$inserted = Db::name('tcm_game_share_visit')->duplicate([
|
||||
'invite_code',
|
||||
])->insert([
|
||||
'invite_code' => $inviteCode,
|
||||
'inviter_user_id' => $inviterUserId,
|
||||
'visitor_user_id' => $userId,
|
||||
'create_time' => time(),
|
||||
]);
|
||||
$accepted = $inserted === 1;
|
||||
if ($accepted) {
|
||||
Db::name('tcm_game_share_invite')->where('id', (int) $invite['id'])->inc('open_count')->update([
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
Db::commit();
|
||||
|
||||
$inviter = Db::name('user')->where('id', $inviterUserId)->field('nickname')->find();
|
||||
return [
|
||||
'accepted' => $accepted,
|
||||
'message' => '已加入控糖消消乐',
|
||||
'inviter' => self::displayName((string) ($inviter['nickname'] ?? ''), $inviterUserId),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
self::logException('acceptShare', $e);
|
||||
return self::fail('分享关系记录失败');
|
||||
}
|
||||
}
|
||||
|
||||
private static function ensureWeeklyScore(int $userId, string $weekStart): array
|
||||
{
|
||||
$profile = self::userProfile($userId);
|
||||
$now = time();
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
|
||||
if ($existing) {
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('id', (int) $existing['id'])
|
||||
->lock(true)
|
||||
->find();
|
||||
$profileChanged = (string) $existing['nickname'] !== $profile['nickname']
|
||||
|| (string) $existing['avatar'] !== $profile['avatar'];
|
||||
if ($profileChanged) {
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$existing['nickname'] = $profile['nickname'];
|
||||
$existing['avatar'] = $profile['avatar'];
|
||||
}
|
||||
|
||||
// 已经进入本周旧“男女榜”的用户也在下次访问时平滑迁入混合榜,
|
||||
// 保留原认糖数、最高分和局数,不要求等到下周重新开始。
|
||||
$oldGroupId = (int) $existing['group_id'];
|
||||
$oldGroupSex = (int) Db::name('tcm_game_weekly_group')
|
||||
->where('id', $oldGroupId)
|
||||
->value('sex');
|
||||
if ($oldGroupSex !== 0) {
|
||||
$mixedGroup = self::lockAvailableMixedGroup($weekStart, $now);
|
||||
Db::name('tcm_game_weekly_score')->where('id', (int) $existing['id'])->update([
|
||||
'group_id' => (int) $mixedGroup['id'],
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$existing['group_id'] = (int) $mixedGroup['id'];
|
||||
self::refreshGroupMemberCount($oldGroupId, $now);
|
||||
self::refreshGroupMemberCount((int) $mixedGroup['id'], $now);
|
||||
}
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$group = self::lockAvailableMixedGroup($weekStart, $now);
|
||||
|
||||
// 等待分配锁期间,同一用户的另一个请求可能已经完成分配。
|
||||
$existing = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$scoreRow = [
|
||||
'group_id' => (int) $group['id'],
|
||||
'week_start' => $weekStart,
|
||||
'user_id' => $userId,
|
||||
'learned_count' => 0,
|
||||
'best_score' => 0,
|
||||
'games_played' => 0,
|
||||
'share_count' => 0,
|
||||
'nickname' => $profile['nickname'],
|
||||
'avatar' => $profile['avatar'],
|
||||
'sex' => $profile['sex'],
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
];
|
||||
$inserted = Db::name('tcm_game_weekly_score')->duplicate([
|
||||
'nickname',
|
||||
'avatar',
|
||||
'sex',
|
||||
'update_time',
|
||||
])->insert($scoreRow);
|
||||
$score = Db::name('tcm_game_weekly_score')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$score) {
|
||||
throw new \RuntimeException('同行成绩创建失败');
|
||||
}
|
||||
|
||||
// 仅新插入时刷新人数;使用实际成绩行数纠正历史并发造成的计数漂移。
|
||||
if ($inserted === 1) {
|
||||
self::refreshGroupMemberCount((int) $group['id'], $now);
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/** 锁定一个可加入的混合7人组;调用方需处在数据库事务中。 */
|
||||
private static function lockAvailableMixedGroup(string $weekStart, int $now): array
|
||||
{
|
||||
// sex=0 是当前统一混合榜。分配锁避免首批并发请求重复建组或超过7人。
|
||||
Db::name('tcm_game_weekly_allocator')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => 0,
|
||||
'create_time'=> $now,
|
||||
'update_time'=> $now,
|
||||
]);
|
||||
$allocator = Db::name('tcm_game_weekly_allocator')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', 0)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$allocator) {
|
||||
throw new \RuntimeException('同行分配锁创建失败');
|
||||
}
|
||||
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', 0)
|
||||
->where('member_count', '<', self::GROUP_SIZE)
|
||||
->order('group_no', 'asc')
|
||||
->lock(true)
|
||||
->find();
|
||||
if ($group) {
|
||||
return $group;
|
||||
}
|
||||
|
||||
$groupNo = (int) Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', 0)
|
||||
->max('group_no') + 1;
|
||||
Db::name('tcm_game_weekly_group')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'week_start' => $weekStart,
|
||||
'sex' => 0,
|
||||
'group_no' => $groupNo,
|
||||
'member_count'=> 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$group = Db::name('tcm_game_weekly_group')
|
||||
->where('week_start', $weekStart)
|
||||
->where('sex', 0)
|
||||
->where('group_no', $groupNo)
|
||||
->lock(true)
|
||||
->find();
|
||||
if (!$group) {
|
||||
throw new \RuntimeException('同行分组创建失败');
|
||||
}
|
||||
return $group;
|
||||
}
|
||||
|
||||
private static function refreshGroupMemberCount(int $groupId, int $now): void
|
||||
{
|
||||
if ($groupId <= 0) {
|
||||
return;
|
||||
}
|
||||
$memberCount = (int) Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', $groupId)
|
||||
->count();
|
||||
Db::name('tcm_game_weekly_group')->where('id', $groupId)->update([
|
||||
'member_count' => min(self::GROUP_SIZE, $memberCount),
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function buildLeaderboard(
|
||||
int $groupId,
|
||||
int $userId,
|
||||
string $inviteCode,
|
||||
?string $weekStart = null
|
||||
): array {
|
||||
$weekStart = $weekStart ?: self::weekStart();
|
||||
$rows = Db::name('tcm_game_weekly_score')
|
||||
->where('group_id', $groupId)
|
||||
->where('week_start', $weekStart)
|
||||
->order('learned_count', 'desc')
|
||||
->order('best_score', 'desc')
|
||||
->order('create_time', 'asc')
|
||||
->limit(self::GROUP_SIZE)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$players = [];
|
||||
$myIndex = 0;
|
||||
foreach ($rows as $index => $row) {
|
||||
$isMe = (int) $row['user_id'] === $userId;
|
||||
if ($isMe) {
|
||||
$myIndex = $index;
|
||||
}
|
||||
$players[] = [
|
||||
// 前端只需要稳定列表键,不暴露平台内部 user_id。
|
||||
'id' => (int) $row['id'],
|
||||
'name' => self::displayName((string) $row['nickname'], (int) $row['user_id']),
|
||||
'avatar' => self::avatarUrl((string) $row['avatar']),
|
||||
'count' => (int) $row['learned_count'],
|
||||
'best_score' => (int) $row['best_score'],
|
||||
'rank' => $index + 1,
|
||||
'is_me' => $isMe,
|
||||
];
|
||||
}
|
||||
|
||||
$me = $players[$myIndex] ?? [
|
||||
'count' => 0,
|
||||
'rank' => 1,
|
||||
'best_score' => 0,
|
||||
];
|
||||
$distance = $myIndex > 0
|
||||
? max(1, (int) $players[$myIndex - 1]['count'] - (int) $me['count'] + 1)
|
||||
: 0;
|
||||
return [
|
||||
'week_start' => $weekStart,
|
||||
'week_end' => date('Y-m-d', strtotime($weekStart . ' +6 days')),
|
||||
'sex' => 0,
|
||||
'sex_label' => '同行',
|
||||
'group_size' => self::GROUP_SIZE,
|
||||
'member_count'=> count($players),
|
||||
'players' => $players,
|
||||
'me' => [
|
||||
'count' => (int) ($me['count'] ?? 0),
|
||||
'rank' => (int) ($me['rank'] ?? 1),
|
||||
'best_score' => (int) ($me['best_score'] ?? 0),
|
||||
'distance' => $distance,
|
||||
'is_first' => $myIndex === 0,
|
||||
],
|
||||
'invite_code' => $inviteCode,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{nickname:string,avatar:string,sex:int} */
|
||||
private static function userProfile(int $userId): array
|
||||
{
|
||||
$user = Db::name('user')
|
||||
->where('id', $userId)
|
||||
->whereNull('delete_time')
|
||||
->field('id,sn,nickname,avatar,sex')
|
||||
->find();
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在');
|
||||
}
|
||||
|
||||
$sex = (int) ($user['sex'] ?? 0);
|
||||
if ($sex !== 1 && $sex !== 2) {
|
||||
$gender = Db::name('diagnosis_view_records')->alias('v')
|
||||
->join('tcm_diagnosis d', 'd.id = v.diagnosis_id')
|
||||
->where('v.user_id', $userId)
|
||||
->whereNull('v.delete_time')
|
||||
->whereNull('d.delete_time')
|
||||
->order('v.id', 'desc')
|
||||
->value('d.gender');
|
||||
if ($gender !== null && $gender !== '') {
|
||||
$sex = (int) $gender === 1 ? 1 : 2;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => self::displayName((string) ($user['nickname'] ?? ''), $userId),
|
||||
'avatar' => (string) ($user['avatar'] ?? ''),
|
||||
'sex' => in_array($sex, [1, 2], true) ? $sex : 0,
|
||||
];
|
||||
}
|
||||
|
||||
private static function displayName(string $nickname, int $userId): string
|
||||
{
|
||||
$nickname = trim(strip_tags($nickname));
|
||||
if ($nickname === '') {
|
||||
return '控糖好友' . substr((string) $userId, -2);
|
||||
}
|
||||
return mb_substr($nickname, 0, 12);
|
||||
}
|
||||
|
||||
private static function avatarUrl(string $avatar): string
|
||||
{
|
||||
return $avatar === '' ? '' : FileService::getFileUrl($avatar);
|
||||
}
|
||||
|
||||
private static function ensureShareInvite(int $userId, string $weekStart): string
|
||||
{
|
||||
$existing = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($existing) {
|
||||
return (string) $existing['invite_code'];
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < 5; $attempt++) {
|
||||
$code = strtoupper(bin2hex(random_bytes(6)));
|
||||
$now = time();
|
||||
// 同一用户并发打开榜单时,以 week_start + user_id 唯一键汇合;
|
||||
// 极小概率随机码撞车时,查询不到本人的记录就继续生成新码。
|
||||
Db::name('tcm_game_share_invite')->duplicate([
|
||||
'update_time',
|
||||
])->insert([
|
||||
'invite_code' => $code,
|
||||
'user_id' => $userId,
|
||||
'week_start' => $weekStart,
|
||||
'open_count' => 0,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
$invite = Db::name('tcm_game_share_invite')
|
||||
->where('week_start', $weekStart)
|
||||
->where('user_id', $userId)
|
||||
->find();
|
||||
if ($invite) {
|
||||
return (string) $invite['invite_code'];
|
||||
}
|
||||
}
|
||||
throw new \RuntimeException('分享码生成失败');
|
||||
}
|
||||
|
||||
private static function weekStart(?int $timestamp = null): string
|
||||
{
|
||||
$timestamp = $timestamp ?: time();
|
||||
$day = (int) date('N', $timestamp);
|
||||
return date('Y-m-d', strtotime('-' . ($day - 1) . ' days', $timestamp));
|
||||
}
|
||||
|
||||
private static function logException(string $action, \Throwable $e): void
|
||||
{
|
||||
Log::error(sprintf(
|
||||
'tcm endless game %s failed: %s at %s:%d',
|
||||
$action,
|
||||
$e->getMessage(),
|
||||
$e->getFile(),
|
||||
$e->getLine()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
-- 控糖消消乐:真实用户周榜、幂等局记录与轻量分享访问关系
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_allocator` (
|
||||
`week_start` date NOT NULL COMMENT '周一日期',
|
||||
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`week_start`, `sex`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周同行分组并发锁';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_group` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`week_start` date NOT NULL COMMENT '周一日期',
|
||||
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女',
|
||||
`group_no` int unsigned NOT NULL DEFAULT 1 COMMENT '同周同性别的分组序号',
|
||||
`member_count` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '组内人数,最多7人',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_week_sex_group` (`week_start`, `sex`, `group_no`),
|
||||
KEY `idx_week_sex_capacity` (`week_start`, `sex`, `member_count`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周7人同行分组';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_score` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`group_id` int unsigned NOT NULL COMMENT '周同行分组ID',
|
||||
`week_start` date NOT NULL COMMENT '周一日期',
|
||||
`user_id` int unsigned NOT NULL COMMENT '小程序用户ID',
|
||||
`learned_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本周认糖数量',
|
||||
`best_score` int unsigned NOT NULL DEFAULT 0 COMMENT '本周单局最高分',
|
||||
`games_played` int unsigned NOT NULL DEFAULT 0 COMMENT '本周完成局数',
|
||||
`share_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本周发起分享次数',
|
||||
`nickname` varchar(32) NOT NULL DEFAULT '' COMMENT '周榜昵称快照',
|
||||
`avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '周榜头像快照',
|
||||
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '性别快照:0未知 1男 2女',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_week_user` (`week_start`, `user_id`),
|
||||
KEY `idx_group_rank` (`group_id`, `learned_count`, `best_score`),
|
||||
KEY `idx_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周认糖成绩';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_session` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`session_key` varchar(64) NOT NULL COMMENT '客户端每局随机标识,用于重试去重',
|
||||
`user_id` int unsigned NOT NULL COMMENT '小程序用户ID',
|
||||
`week_start` date NOT NULL COMMENT '该局计入的周一日期',
|
||||
`learned_count` int unsigned NOT NULL DEFAULT 0 COMMENT '本局已确认认糖数量',
|
||||
`last_score` int unsigned NOT NULL DEFAULT 0 COMMENT '本局最新分数',
|
||||
`ended` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '是否已经结束并计入完成局数',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_session_key` (`session_key`),
|
||||
KEY `idx_user_week` (`user_id`, `week_start`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐局进度去重';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_share_invite` (
|
||||
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||
`invite_code` varchar(16) NOT NULL COMMENT '不包含用户ID的分享码',
|
||||
`user_id` int unsigned NOT NULL COMMENT '分享人小程序用户ID',
|
||||
`week_start` date NOT NULL COMMENT '分享码所属周',
|
||||
`open_count` int unsigned NOT NULL DEFAULT 0 COMMENT '去重后的受邀打开人数',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_invite_code` (`invite_code`),
|
||||
UNIQUE KEY `uk_week_user` (`week_start`, `user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐微信分享码';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_share_visit` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`invite_code` varchar(16) NOT NULL COMMENT '来源分享码',
|
||||
`inviter_user_id` int unsigned NOT NULL COMMENT '分享人用户ID',
|
||||
`visitor_user_id` int unsigned NOT NULL COMMENT '受邀打开用户ID',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_invite_visitor` (`invite_code`, `visitor_user_id`),
|
||||
KEY `idx_inviter` (`inviter_user_id`),
|
||||
KEY `idx_visitor` (`visitor_user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐分享打开记录(不做强绑定)';
|
||||
@@ -0,0 +1,55 @@
|
||||
-- 仅用于已经执行过旧版小游戏建表脚本的数据库。
|
||||
-- 新安装直接执行 add_tcm_endless_game_platform.sql 即可。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `zyt_tcm_game_weekly_allocator` (
|
||||
`week_start` date NOT NULL COMMENT '周一日期',
|
||||
`sex` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '同行组性别:0未知 1男 2女',
|
||||
`create_time` int unsigned NOT NULL DEFAULT 0,
|
||||
`update_time` int unsigned NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`week_start`, `sex`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='控糖消消乐每周同行分组并发锁';
|
||||
|
||||
SET @has_old_visit_index = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'zyt_tcm_game_share_visit'
|
||||
AND index_name = 'uk_inviter_visitor'
|
||||
);
|
||||
SET @drop_old_visit_index_sql = IF(
|
||||
@has_old_visit_index > 0,
|
||||
'ALTER TABLE `zyt_tcm_game_share_visit` DROP INDEX `uk_inviter_visitor`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE drop_old_visit_index_stmt FROM @drop_old_visit_index_sql;
|
||||
EXECUTE drop_old_visit_index_stmt;
|
||||
DEALLOCATE PREPARE drop_old_visit_index_stmt;
|
||||
|
||||
SET @has_invite_visit_index = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'zyt_tcm_game_share_visit'
|
||||
AND index_name = 'uk_invite_visitor'
|
||||
);
|
||||
SET @add_invite_visit_index_sql = IF(
|
||||
@has_invite_visit_index = 0,
|
||||
'ALTER TABLE `zyt_tcm_game_share_visit` ADD UNIQUE INDEX `uk_invite_visitor` (`invite_code`, `visitor_user_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_invite_visit_index_stmt FROM @add_invite_visit_index_sql;
|
||||
EXECUTE add_invite_visit_index_stmt;
|
||||
DEALLOCATE PREPARE add_invite_visit_index_stmt;
|
||||
|
||||
SET @has_inviter_index = (
|
||||
SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'zyt_tcm_game_share_visit'
|
||||
AND index_name = 'idx_inviter'
|
||||
);
|
||||
SET @add_inviter_index_sql = IF(
|
||||
@has_inviter_index = 0,
|
||||
'ALTER TABLE `zyt_tcm_game_share_visit` ADD INDEX `idx_inviter` (`inviter_user_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_inviter_index_stmt FROM @add_inviter_index_sql;
|
||||
EXECUTE add_inviter_index_stmt;
|
||||
DEALLOCATE PREPARE add_inviter_index_stmt;
|
||||