可信源码风险提示:保存或下载前请阅读源码和权限声明。
const DATA_KEY = 'stack_joy_data';
const SKINS = {
default: { name: '经典', price: 0, bg: '#0a0d14',
colors: ['#ff5252','#ff9800','#ffd600','#8bc34a','#4fc3f7','#7c4dff','#f06292','#4db6ac'] },
forest: { name: '森林', price: 30, bg: '#0e1e12',
colors: ['#e8f5e9','#c8e6c9','#a5d6a7','#81c784','#66bb6a','#4caf50','#43a047','#2e7d32'] },
sunset: { name: '日落', price: 60, bg: '#1f0e08',
colors: ['#fff3e0','#ffe0b2','#ffcc80','#ffb74d','#ffa726','#ff9800','#fb8c00','#ef6c00'] },
galaxy: { name: '星空', price: 100, bg: '#0a0a1e',
colors: ['#ede7f6','#d1c4e9','#b39ddb','#9575cd','#7c4dff','#651fff','#536dfe','#5c6bc0'] }
};
const ITEMS = {
widen: { name: '加宽', price: 10 },
revive: { name: '复活', price: 20 },
auto: { name: '对齐', price: 15 }
};
const W = 400, H = 300, BLOCK_H = 15, GAP = 2, BASE_W = 100;
const PERFECT = 13, SNAP = 4;
const SPEED_DEFAULT = 5;
const SPEED_STEP = 0.25;
let DATA = {
best: 0, points: 0, skin: 'default', owned: ['default'],
speed: SPEED_DEFAULT,
cards: { widen: 0, revive: 0, auto: 0 }
};
let tower = [], curX = 0, curW = BASE_W, curColor = '#fff';
let curVx = SPEED_DEFAULT;
let score = 0, streak = 0, moveTimer = null;
let playing = false, over = false;
let effects = { widen: false };
let screen = 'home';
async function save() {
try {
const snap = JSON.parse(JSON.stringify(DATA));
await script.data.set(DATA_KEY, snap);
} catch (e) { console.error('save fail', e); }
}
async function loadData() {
try {
const s = await script.data.get(DATA_KEY, null);
if (!s || typeof s !== 'object') return;
DATA = {
best: Number(s.best) || 0,
points: Number(s.points) || 0,
skin: typeof s.skin === 'string' ? s.skin : 'default',
owned: Array.isArray(s.owned) ? s.owned : ['default'],
speed: (typeof s.speed === 'number' && isFinite(s.speed)) ? s.speed : SPEED_DEFAULT,
cards: {
widen: Number((s.cards || {}).widen) || 0,
revive: Number((s.cards || {}).revive) || 0,
auto: Number((s.cards || {}).auto) || 0
}
};
if (!DATA.owned.includes('default')) DATA.owned.push('default');
if (!DATA.owned.includes(DATA.skin)) DATA.skin = 'default';
} catch (e) { console.error('load fail', e); }
}
function skin() { return SKINS[DATA.skin] || SKINS.default; }
function stopTimer() { if (moveTimer) { clearInterval(moveTimer); moveTimer = null; } }
function placeNext() {
if (curVx > 0) curX = 0;
else if (curVx < 0) curX = W - curW;
else curX = (W - curW) / 2;
curColor = skin().colors[tower.length % skin().colors.length];
}
function startGame() {
stopTimer();
tower = [{ x: (W - BASE_W) / 2, w: BASE_W, color: skin().colors[0] }];
curW = BASE_W;
curVx = DATA.speed;
if (curVx > 0) curX = 0;
else if (curVx < 0) curX = W - curW;
else curX = (W - curW) / 2;
curColor = skin().colors[1];
score = 0;
streak = 0;
playing = true;
over = false;
effects = { widen: false };
screen = 'game';
startMove();
render();
}
function startMove() {
stopTimer();
moveTimer = setInterval(() => {
if (!playing || over) return;
let nx = curX + curVx;
// 直接反射:越界多少就反弹多少,不钳制到边缘
let guard = 0;
while (guard++ < 50) {
if (nx <= 0) {
nx = -nx;
curVx = Math.abs(curVx);
} else if (nx + curW >= W) {
nx = 2 * (W - curW) - nx;
curVx = -Math.abs(curVx);
} else {
break;
}
}
curX = nx;
if (screen === 'game') render();
}, 50);
}
async function useCard(type) {
if (!playing || over) return;
if ((DATA.cards[type] || 0) <= 0) return;
if (type === 'widen' && !effects.widen) {
DATA.cards.widen--;
effects.widen = true;
render();
await save();
} else if (type === 'auto') {
DATA.cards.auto--;
await save();
autoDrop();
}
}
function autoDrop() {
if (!playing || over) return;
const last = tower[tower.length - 1];
const dL = Math.abs(curX - last.x);
const dR = Math.abs(curX + curW - last.x - last.w);
curX = dL <= dR ? last.x : last.x + last.w - curW;
drop();
}
async function drop() {
if (!playing || over) return;
const last = tower[tower.length - 1];
const dL = Math.abs(curX - last.x);
const dR = Math.abs(curX + curW - last.x - last.w);
const minDiff = Math.min(dL, dR);
if (minDiff <= PERFECT) {
const perfectX = dL <= dR ? last.x : last.x + last.w - curW;
tower.push({ x: perfectX, w: curW, color: curColor });
streak++;
score += 1 + streak;
placeNext();
if (tower.length % 5 === 0 && curVx !== 0) {
curVx += curVx > 0 ? SPEED_STEP : -SPEED_STEP;
}
render();
return;
}
let snapX = curX;
if (minDiff <= SNAP) snapX = dL <= dR ? last.x : last.x + last.w - curW;
let left = Math.max(snapX, last.x);
let right = Math.min(snapX + curW, last.x + last.w);
let ow = right - left;
if (effects.widen && ow > 0.5 && ow < curW - 0.1) {
const cut = curW - ow;
left -= cut / 4;
right += cut / 4;
ow = right - left;
effects.widen = false;
}
if (ow <= 0.5) {
if ((DATA.cards.revive || 0) > 0) {
DATA.cards.revive--;
await save();
curW = Math.max(curW, 40);
placeNext();
streak = 0;
render();
return;
}
await endGame();
return;
}
tower.push({ x: left, w: ow, color: curColor });
curW = ow;
streak = 0;
score += 1;
placeNext();
if (tower.length % 5 === 0 && curVx !== 0) {
curVx += curVx > 0 ? SPEED_STEP : -SPEED_STEP;
}
render();
}
async function endGame() {
over = true;
playing = false;
stopTimer();
if (score > DATA.best) DATA.best = score;
DATA.points += score;
screen = 'over';
render();
await save();
}
function renderBoard() {
const n = tower.length;
const nodes = [];
for (let i = 0; i < n; i++) {
const b = tower[i];
const y = H - BLOCK_H * (i + 1);
if (y < -BLOCK_H || y > H) continue;
nodes.push(ui.stack([], {
x: b.x, y: y, width: b.w, height: BLOCK_H - GAP,
background: b.color, radius: 2
}));
}
if (!over && n > 0) {
const y = H - BLOCK_H * (n + 1);
if (y > -BLOCK_H && y < H) {
nodes.push(ui.stack([], {
x: curX, y: y, width: curW, height: BLOCK_H - GAP,
background: curColor, radius: 2
}));
}
}
return ui.stack(nodes, { width: W, height: H, background: skin().bg, radius: 6 });
}
async function editSpeed() {
const res = await dialog.number({
title: '速度设置',
message: '任意数值(可为负数或 0)。正数向右开始,负数向左开始,0 静止',
value: DATA.speed,
decimals: 2
});
if (res.action !== 'confirm') return;
const v = Number(res.value);
if (!isFinite(v)) { script.toast('无效数值'); return; }
DATA.speed = v;
render();
await save();
}
function renderHome() {
ui.render([
ui.heading('叠叠乐', { align: 'center', lines: 1 }),
ui.spacer(10),
ui.column([
ui.text('最佳 ' + DATA.best, { align: 'center', color: '#ffd75e', size: 18, lines: 1 }),
ui.text('积分 ' + DATA.points, { align: 'center', color: '#52c41a', size: 18, lines: 1 }),
ui.text('速度 ' + DATA.speed, { align: 'center', color: '#aaddff', size: 14, lines: 1 })
], { background: '#1c1c1e', radius: 8, padding: 12, justify: 'center' }),
ui.spacer(10),
ui.button('开始游戏', startGame, { tone: 'primary', height: 50 }),
ui.spacer(6),
ui.button('速度设置', editSpeed, { tone: 'warning', height: 44 }),
ui.spacer(6),
ui.button('积分商店', () => { screen = 'shop'; render(); }, { tone: 'neutral', height: 44 })
]);
}
function renderGame() {
const n = tower.length;
const children = [
ui.row([
ui.text('分 ' + score, { flex: 1, size: 13, color: '#aaddff', align: 'center', lines: 1 }),
ui.text('层 ' + Math.max(0, n - 1), { flex: 1, size: 13, color: '#aaddff', align: 'center', lines: 1 }),
ui.text('积分 ' + DATA.points, { flex: 1, size: 13, color: '#52c41a', align: 'center', lines: 1 }),
ui.text('速 ' + (Math.round(curVx * 100) / 100), { flex: 1, size: 13, color: '#ffd75e', align: 'center', lines: 1 })
], { gap: 4 }),
ui.row([renderBoard()], { justify: 'center' })
];
if (streak >= 2) {
children.push(ui.text('完美连击 x' + streak, { align: 'center', color: '#ffd75e', size: 13, lines: 1 }));
}
if (over) {
children.push(ui.text('没接住 · 得分 ' + score + ' 已入积分', { align: 'center', color: '#ff453a', size: 14, lines: 1 }));
children.push(ui.button('再来一局', startGame, { tone: 'primary', height: 44 }));
children.push(ui.button('返回主页', () => { screen = 'home'; render(); }, { tone: 'neutral', height: 36 }));
} else {
children.push(ui.button('放下', drop, { tone: 'primary', height: 46 }));
const btns = [];
if (DATA.cards.widen > 0 && !effects.widen)
btns.push(ui.button('加宽 x' + DATA.cards.widen, () => useCard('widen'), { flex: 1, tone: 'warning', height: 36 }));
if (DATA.cards.auto > 0)
btns.push(ui.button('对齐 x' + DATA.cards.auto, () => useCard('auto'), { flex: 1, tone: 'primary', height: 36 }));
if (btns.length > 0) children.push(ui.row(btns, { gap: 6 }));
if (effects.widen)
children.push(ui.text('加宽中', { align: 'center', color: '#ffd75e', size: 12, lines: 1 }));
}
ui.render(children);
}
function renderShop() {
const children = [
ui.heading('积分商店', { align: 'center', lines: 1 }),
ui.text('积分 ' + DATA.points, { align: 'center', color: '#52c41a', size: 16, lines: 1 }),
ui.spacer(6),
ui.text('皮肤', { size: 15, color: '#4fc3f7', lines: 1 })
];
for (const id in SKINS) {
const sk = SKINS[id];
const owned = DATA.owned.includes(id);
const equipped = DATA.skin === id;
let actionBtn;
if (owned) {
if (equipped) {
actionBtn = (id === 'default')
? ui.text('使用中', { size: 13, color: '#888888', flex: 1, align: 'center', lines: 1 })
: ui.button('卸下', async () => {
DATA.skin = 'default';
render();
await save();
}, { size: 13, tone: 'neutral', flex: 1, height: 36 });
} else {
actionBtn = ui.button('装备', async () => {
DATA.skin = id;
render();
await save();
}, { size: 13, tone: 'primary', flex: 1, height: 36 });
}
} else {
actionBtn = ui.button('买 ' + sk.price, async () => {
if (DATA.points >= sk.price) {
DATA.points -= sk.price;
DATA.owned.push(id);
DATA.skin = id;
render();
await save();
} else { script.toast('积分不足'); }
}, { size: 13, tone: 'primary', flex: 1, height: 36 });
}
children.push(ui.row([
ui.text(sk.name, { size: 14, width: 70, lines: 1 }),
actionBtn
], { gap: 8 }));
}
children.push(ui.spacer(6));
children.push(ui.text('道具', { size: 15, color: '#4fc3f7', lines: 1 }));
for (const id in ITEMS) {
const it = ITEMS[id];
const cnt = DATA.cards[id] || 0;
children.push(ui.row([
ui.text(it.name + ' x' + cnt, { size: 14, flex: 1, lines: 1 }),
ui.button('买 ' + it.price, async () => {
if (DATA.points >= it.price) {
DATA.points -= it.price;
DATA.cards[id] = (DATA.cards[id] || 0) + 1;
render();
await save();
} else { script.toast('积分不足'); }
}, { size: 13, tone: 'primary', flex: 1, height: 36 })
], { gap: 8 }));
}
children.push(ui.spacer(8));
children.push(ui.button('返回主页', () => { screen = 'home'; render(); }, { tone: 'neutral', height: 40 }));
children.push(ui.button('重置存档', async () => {
const res = await dialog.confirm({
title: '重置', message: '确定要清空所有数据吗?',
confirmText: '清空', cancelText: '取消'
});
if (res.action === 'confirm') {
DATA = {
best: 0, points: 0, skin: 'default', owned: ['default'],
speed: SPEED_DEFAULT,
cards: { widen: 0, revive: 0, auto: 0 }
};
render();
await save();
}
}, { tone: 'danger', height: 36 }));
ui.render(children);
}
function render() {
if (screen === 'home') renderHome();
else if (screen === 'shop') renderShop();
else renderGame();
}
async function main() {
await loadData();
render();
}
return main();