MysteryBlocks / script.js
miya
各種ソースコードの追加・更新
bf66336
(() => {
const WIDTH = 6;
const HEIGHT = 12;
const CELL = 40;
const PARTICLES_PER_BLOCK = 10;
const PARTICLE_LIFETIME = 600;
const UNKNOWN_PROBABILITY = 0.3;
const SCORE_PER_BLOCK = 10;
const COLORS = [
{key: 'R', fill: '#d33'},
{key: 'G', fill: '#3c3'},
{key: 'B', fill: '#36f'},
{key: 'Y', fill: '#ee3'},
];
const COLOR_MAP = Object.fromEntries(COLORS.map(c => [c.key, c.fill]));
const OFFSETS = [
{x: 0, y: 1},
{x: 1, y: 0},
{x: 0, y: -1},
{x: -1, y: 0},
];
class QuantumBlock {
constructor(amplitudes) {
this.amplitudes = amplitudes || Object.fromEntries(COLORS.map(c => [c.key, 1 / COLORS.length]));
this.collapsedColor = null;
}
isCollapsed() {
return this.collapsedColor !== null;
}
collapse() {
if (this.collapsedColor) return this.collapsedColor;
const r = Math.random();
let sum = 0;
for (const [key, prob] of Object.entries(this.amplitudes)) {
sum += prob;
if (r < sum) {
this.collapsedColor = key;
break;
}
}
if (!this.collapsedColor) this.collapsedColor = COLORS[COLORS.length - 1].key;
return this.collapsedColor;
}
}
class FallingPair {
constructor(unknownProb = UNKNOWN_PROBABILITY) {
this.x = Math.floor(WIDTH / 2);
this.y = -1;
this.orientation = 0;
this.a = new QuantumBlock();
this.a.collapse();
this.b = new QuantumBlock();
if (Math.random() > unknownProb) {
this.b.collapse();
}
}
blocks() {
const {x: dx, y: dy} = OFFSETS[this.orientation];
return [
{x: this.x, y: this.y, block: this.a},
{x: this.x + dx, y: this.y + dy, block: this.b},
];
}
rotateCW() {
this.orientation = (this.orientation + 1) % 4;
}
rotateCCW() {
this.orientation = (this.orientation + 3) % 4;
}
}
class Game {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.board = Array.from({length: HEIGHT}, () => Array(WIDTH).fill(null));
this.particles = [];
this.pair = new FallingPair();
this.nextPair = new FallingPair();
this.nextCanvas = document.getElementById('nextCanvas');
this.nextCtx = this.nextCanvas ? this.nextCanvas.getContext('2d') : null;
if (this.nextCanvas) {
this.nextCanvas.width = CELL * 1;
this.nextCanvas.height = CELL * 2;
}
this.score = 0;
this.scoreElem = document.getElementById('score');
this.updateScoreUI();
this.lastFall = performance.now();
this.fallInterval = 500;
this.running = true;
this.bindKeys();
requestAnimationFrame(this.loop.bind(this));
this.drawNextPreview();
}
inBounds(x, y) {
return x >= 0 && x < WIDTH && y >= -1 && y < HEIGHT;
}
fits(pair) {
for (const {x, y} of pair.blocks()) {
if (!this.inBounds(x, y)) return false;
if (y >= 0 && this.board[y][x]) return false;
}
return true;
}
lockPair() {
const landedBlocks = this.pair.blocks();
for (const {x, y, block} of landedBlocks) {
if (y < 0) {
this.gameOver();
return;
}
this.board[y][x] = block;
}
for (const {block} of landedBlocks) {
if (!block.isCollapsed()) block.collapse();
}
this.applyGravity();
this.resolveChains();
this.pair = this.nextPair;
this.nextPair = new FallingPair();
this.drawNextPreview();
if (!this.fits(this.pair)) {
this.gameOver();
}
}
applyGravity() {
let moved;
do {
moved = false;
for (let y = HEIGHT - 2; y >= 0; --y) {
for (let x = 0; x < WIDTH; ++x) {
const block = this.board[y][x];
if (block && !this.board[y + 1][x]) {
this.board[y + 1][x] = block;
this.board[y][x] = null;
moved = true;
}
}
}
} while (moved);
}
measureAll() {
for (const row of this.board) {
for (const block of row) if (block) block.collapse();
}
for (const {block} of this.pair.blocks()) block.collapse();
this.resolveChains();
}
resolveChains() {
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
let popped;
do {
popped = false;
const visited = Array.from({length: HEIGHT}, () => Array(WIDTH).fill(false));
let poppedThisRound = 0;
for (let y = 0; y < HEIGHT; ++y) {
for (let x = 0; x < WIDTH; ++x) {
if (visited[y][x] || !this.board[y][x]) continue;
const color = this.board[y][x].collapsedColor;
const group = [];
const stack = [[x, y]];
while (stack.length) {
const [cx, cy] = stack.pop();
if (cx < 0 || cx >= WIDTH || cy < 0 || cy >= HEIGHT) continue;
if (visited[cy][cx]) continue;
const cell = this.board[cy][cx];
if (!cell || cell.collapsedColor !== color) continue;
visited[cy][cx] = true;
group.push([cx, cy]);
for (const [dx, dy] of dirs) stack.push([cx + dx, cy + dy]);
}
if (group.length >= 4) {
popped = true;
for (const [gx, gy] of group) {
this.board[gy][gx] = null;
this.spawnParticles(gx, gy, color);
}
poppedThisRound += group.length;
}
}
}
if (popped) {
if (poppedThisRound > 0) {
this.addScore(poppedThisRound * SCORE_PER_BLOCK);
}
this.applyGravity();
}
} while (popped);
}
addScore(amount) {
this.score += amount;
this.updateScoreUI();
}
updateScoreUI() {
if (this.scoreElem) {
this.scoreElem.textContent = `Score: ${this.score}`;
}
}
update(dt) {
const now = performance.now();
this.particles = this.particles.filter(p => now - p.birth < PARTICLE_LIFETIME);
for (const p of this.particles) {
const age = now - p.birth;
const t = age / PARTICLE_LIFETIME;
p.x += p.vx;
p.y += p.vy;
p.vy += 0.05;
p.alpha = 1 - t;
}
if (now - this.lastFall >= this.fallInterval) {
this.pair.y += 1;
if (!this.fits(this.pair)) {
this.pair.y -= 1;
this.lockPair();
}
this.lastFall = now;
}
}
draw() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (let y = 0; y < HEIGHT; ++y) {
for (let x = 0; x < WIDTH; ++x) {
const block = this.board[y][x];
if (block) this.drawBlock(x, y, block);
}
}
for (const {x, y, block} of this.pair.blocks()) {
if (y >= 0) this.drawBlock(x, y, block, true);
}
for (const p of this.particles) {
ctx.save();
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.strokeStyle = '#555';
for (let x = 0; x <= WIDTH; ++x) {
ctx.beginPath();
ctx.moveTo(x * CELL, 0);
ctx.lineTo(x * CELL, HEIGHT * CELL);
ctx.stroke();
}
for (let y = 0; y <= HEIGHT; ++y) {
ctx.beginPath();
ctx.moveTo(0, y * CELL);
ctx.lineTo(WIDTH * CELL, y * CELL);
ctx.stroke();
}
this.drawNextPreview();
}
drawNextPreview() {
if (!this.nextCtx || !this.nextCanvas) return;
const ctx = this.nextCtx;
const CANVAS_W = this.nextCanvas.width;
const CANVAS_H = this.nextCanvas.height;
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
const previewPair = this.nextPair;
if (!previewPair) return;
const blocks = previewPair.blocks();
blocks.forEach(({block}, idx) => {
const px = 0;
const py = idx * CELL;
ctx.save();
if (block.isCollapsed()) {
ctx.fillStyle = COLOR_MAP[block.collapsedColor] || '#888';
ctx.fillRect(px + 2, py + 2, CELL - 4, CELL - 4);
} else {
ctx.fillStyle = '#666';
ctx.fillRect(px + 4, py + 4, CELL - 8, CELL - 8);
ctx.fillStyle = '#ddd';
ctx.font = '20px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('?', px + CELL / 2, py + CELL / 2 + 1);
}
ctx.restore();
});
ctx.strokeStyle = '#555';
for (let gx = 0; gx <= 1; ++gx) {
ctx.beginPath();
ctx.moveTo(gx * CELL, 0);
ctx.lineTo(gx * CELL, CELL * 2);
ctx.stroke();
}
for (let gy = 0; gy <= 2; ++gy) {
ctx.beginPath();
ctx.moveTo(0, gy * CELL);
ctx.lineTo(CELL, gy * CELL);
ctx.stroke();
}
}
drawBlock(x, y, block, outline = false) {
const ctx = this.ctx;
const px = x * CELL;
const py = y * CELL;
ctx.save();
if (block.isCollapsed()) {
ctx.fillStyle = COLOR_MAP[block.collapsedColor] || '#888';
ctx.fillRect(px + 2, py + 2, CELL - 4, CELL - 4);
} else {
ctx.fillStyle = '#666';
ctx.fillRect(px + 4, py + 4, CELL - 8, CELL - 8);
ctx.fillStyle = '#ddd';
ctx.font = '20px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('?', px + CELL / 2, py + CELL / 2 + 1);
}
if (outline) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(px + 1, py + 1, CELL - 2, CELL - 2);
}
ctx.restore();
}
loop(now) {
if (!this.running) return;
this.update(now);
this.draw();
requestAnimationFrame(this.loop.bind(this));
}
bindKeys() {
window.addEventListener('keydown', (e) => {
if (e.key === 'q' || e.key === 'Q') {
location.reload();
return;
}
if (!this.running) return;
switch (e.key) {
case 'ArrowLeft':
this.pair.x -= 1;
if (!this.fits(this.pair)) this.pair.x += 1;
break;
case 'ArrowRight':
this.pair.x += 1;
if (!this.fits(this.pair)) this.pair.x -= 1;
break;
case 'ArrowDown':
this.pair.y += 1;
if (!this.fits(this.pair)) {
this.pair.y -= 1;
this.lockPair();
}
break;
case 'ArrowUp':
this.pair.rotateCW();
if (!this.fits(this.pair)) this.pair.rotateCCW();
break;
case 'm':
case 'M':
case ' ':
this.measureAll();
break;
}
});
}
gameOver() {
this.running = false;
this.draw();
const ctx = this.ctx;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, this.canvas.height / 2 - 40, this.canvas.width, 80);
ctx.fillStyle = '#fff';
ctx.font = '28px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('GAME OVER', this.canvas.width / 2, this.canvas.height / 2 - 14);
ctx.font = '16px monospace';
ctx.fillText(`Score: ${this.score}`, this.canvas.width / 2, this.canvas.height / 2 + 10);
ctx.fillText('Press Q to restart', this.canvas.width / 2, this.canvas.height / 2 + 32);
}
}
Game.prototype.spawnParticles = function(gridX, gridY, colorKey) {
const color = COLOR_MAP[colorKey] || '#fff';
const baseX = gridX * CELL + CELL / 2;
const baseY = gridY * CELL + CELL / 2;
const now = performance.now();
for (let i = 0; i < PARTICLES_PER_BLOCK; ++i) {
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 2 + 1;
this.particles.push({
x: baseX,
y: baseY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius: Math.random() * 3 + 1,
color,
birth: now,
alpha: 1,
});
}
};
const canvas = document.getElementById('gameCanvas');
canvas.width = WIDTH * CELL;
canvas.height = HEIGHT * CELL;
new Game(canvas);
})();