import * as THREE from 'three' import { scheduler } from './scheduler.js' import { TileMaterial } from './TileMaterial.js' Array.prototype.pick = function () { return this.splice(Math.floor(Math.random() * this.length), 1)[0] } let P = (x, y, z = 0) => new THREE.Vector3(x, y, z) const GRAVITY = -30 const COLORS = { I: 0xafeff9, J: 0xb8b4ff, L: 0xfdd0b7, O: 0xffedac, S: 0xC8FBA8, T: 0xedb2ff, Z: 0xffb8c5, LOCKING: 0xffffff, GHOST: 0x99a9b2, EDGE: 0x88abe0, RETRO: 0xffffff, } const TRANSLATION = { NONE : P( 0, 0), LEFT : P(-1, 0), RIGHT: P( 1, 0), UP : P( 0, 1), DOWN : P( 0, -1), } const ROTATION = { CW: 1, // ClockWise CCW: 3, // CounterClockWise } const T_SPIN = { NONE: "", MINI: "PETITE
PIROUETTE", T_SPIN: "PIROUETTE" } const FACING = { NORTH: 0, EAST: 1, SOUTH: 2, WEST: 3, } const ROWS = 24 const SKYLINE = 20 const COLUMNS = 10 const envRenderTarget = new THREE.WebGLCubeRenderTarget(256) const environment = envRenderTarget.texture environment.type = THREE.HalfFloatType environment.camera = new THREE.CubeCamera(1, 1000, envRenderTarget) environment.camera.position.set(5, 10, 0) const sideMaterial = new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.8, metalness: 0.8, }) class InstancedMino extends THREE.InstancedMesh { constructor(geometry, material, count) { super(geometry, material, count) this.instances = new Set() this.count = 0 this.offsets = new Uint8Array(2*count) this.update = this.updateColor } add(instance) { this.instances.add(instance) } delete(instance) { this.instances.delete(instance) } clear() { this.instances.clear() } setOffsetAt(index, offset) { this.offsets[index * 2] = offset } resetColor() { this.instanceColor = null } updateColor() { this.count = 0 this.instances.forEach(mino => { if (mino.parent?.visible) { this.setMatrixAt(this.count, mino.matrixWorld) this.setColorAt(this.count, mino.color) this.count++ } }) if (this.count) { this.instanceMatrix.needsUpdate = true this.instanceColor.needsUpdate = true } } updateOffset() { this.count = 0 this.instances.forEach(mino => { if (mino.parent?.visible) { this.setMatrixAt(this.count, mino.matrixWorld) this.setOffsetAt(this.count, mino.offset) this.count++ } }) if (this.count) { this.instanceMatrix.needsUpdate = true this.geometry.setAttribute('offset', new THREE.InstancedBufferAttribute(this.offsets, 2)) } } } class Mino extends THREE.Object3D { static materials = { Plasma: new THREE.MeshStandardMaterial({ envMap: environment, side: THREE.DoubleSide, transparent: true, opacity: 0.8, roughness: 0.48, metalness: 0.67, }), Espace: new THREE.MeshStandardMaterial({ envMap: environment, side: THREE.DoubleSide, transparent: true, opacity: 0.8, roughness: 0.1, metalness: 0.99, }), Rétro: [sideMaterial, sideMaterial, sideMaterial, sideMaterial, sideMaterial, sideMaterial] } static { new THREE.TextureLoader().load("images/sprites.png", (texture) => { this.materials.Rétro[0] = this.materials.Rétro[2] = new TileMaterial({ color: 0xd0d4c1, map: texture, bumpMap: texture, bumpScale: 1.5, roughness: 0.25, metalness: 0.8, transparent: true, }, 8, 8) }) } static meshes static { let minoFaceShape = new THREE.Shape() minoFaceShape.moveTo(.1, .1) minoFaceShape.lineTo(.1, .9) minoFaceShape.lineTo(.9, .9) minoFaceShape.lineTo(.9, .1) minoFaceShape.lineTo(.1, .1) let minoExtrudeSettings = { steps: 1, depth: .8, bevelEnabled: true, bevelThickness: .1, bevelSize: .1, bevelOffset: 0, bevelSegments: 1 } let minoGeometry = new THREE.ExtrudeGeometry(minoFaceShape, minoExtrudeSettings) this.meshes = new InstancedMino(minoGeometry, this.materials.Plasma, 2*ROWS*COLUMNS) } constructor(color, offset) { super() this.color = color this.offset = offset this.velocity = P(50 - 100 * Math.random(), 50 - 100 * Math.random(), 50 - 100 * Math.random()) this.rotationAngle = P(Math.random(), Math.random(), Math.random()).normalize() this.angularVelocity = 5 - 10 * Math.random() this.constructor.meshes.add(this) } explode(delta) { this.velocity.y += delta * GRAVITY this.position.addScaledVector(this.velocity, delta) this.rotateOnWorldAxis(this.rotationAngle, delta * this.angularVelocity) if (Math.sqrt(this.position.x * this.position.x + this.position.z * this.position.z) > 40 || this.position.y < -50) { this.dispose() return true } else { return false } } dispose() { this.constructor.meshes.delete(this) } } class Tetromino extends THREE.Group { static randomBag = [] static get random() { if (!this.randomBag.length) this.randomBag = [I, J, L, O, S, T, Z] return this.randomBag.pick() } constructor(position) { super() if (position) this.position.copy(position) this.minoesPosition[FACING.NORTH].forEach(() => this.add(new Mino(this.freeColor, this.offset))) this.facing = FACING.NORTH this.rotatedLast = false this.rotationPoint4Used = false this.holdEnabled = true this.locking = false } set facing(facing) { this._facing = facing this.children.forEach((mino, i) => mino.position.copy(this.minoesPosition[facing][i])) } get facing() { return this._facing } set locking(locking) { if (locking) { this.color = this.lockingColor } else { this.color = this.freeColor } } set color(color) { this.children.forEach((mino) => mino.color = color) } canMove(translation, facing=this.facing) { let testPosition = this.position.clone().add(translation) return this.minoesPosition[facing].every(minoPosition => this.parent?.cellIsEmpty(minoPosition.clone().add(testPosition))) } move(translation, rotatedFacing, rotationPoint) { if (this.canMove(translation, rotatedFacing)) { this.position.add(translation) this.rotatedLast = rotatedFacing if (rotatedFacing != undefined) { this.facing = rotatedFacing if (rotationPoint == 4) this.rotationPoint4Used = true } if (this.canMove(TRANSLATION.DOWN)) { this.locking = false this.parent?.ghost.copy(this) scheduler.clearTimeout(this.onLockDown) } else { scheduler.resetTimeout(this.onLockDown, this.lockDelay) this.locking = true this.parent.ghost.visible = false } return true } else if (translation == TRANSLATION.DOWN) { this.locked = true if (!scheduler.timeoutTasks.has(this.onLockDown)) scheduler.setTimeout(this.onLockDown, this.lockDelay) } } rotate(rotation) { let testFacing = (this.facing + rotation) % 4 return this.srs[this.facing][rotation].some( (translation, rotationPoint) => this.move(translation, testFacing, rotationPoint) ) } get tSpin() { return T_SPIN.NONE } } Tetromino.prototype.lockingColor = new THREE.Color(COLORS.LOCKING) // Super Rotation System // freedom of movement = srs[this.parent.piece.facing][rotation] Tetromino.prototype.srs = [ { [ROTATION.CW]: [P(0, 0), P(-1, 0), P(-1, 1), P(0, -2), P(-1, -2)], [ROTATION.CCW]: [P(0, 0), P(1, 0), P(1, 1), P(0, -2), P(1, -2)] }, { [ROTATION.CW]: [P(0, 0), P(1, 0), P(1, -1), P(0, 2), P(1, 2)], [ROTATION.CCW]: [P(0, 0), P(1, 0), P(1, -1), P(0, 2), P(1, 2)] }, { [ROTATION.CW]: [P(0, 0), P(1, 0), P(1, 1), P(0, -2), P(1, -2)], [ROTATION.CCW]: [P(0, 0), P(-1, 0), P(-1, 1), P(0, -2), P(-1, -2)] }, { [ROTATION.CW]: [P(0, 0), P(-1, 0), P(-1, -1), P(0, 2), P(-1, 2)], [ROTATION.CCW]: [P(0, 0), P(-1, 0), P(-1, -1), P(0, 2), P(-1, 2)] }, ] Tetromino.prototype.lockDelay = 500 class Ghost extends Tetromino { copy(piece) { this.position.copy(piece.position) this.minoesPosition = piece.minoesPosition //this.children.forEach(mino => mino.offset = piece.offset) this.facing = piece.facing this.visible = true while (this.canMove(TRANSLATION.DOWN)) this.position.y-- } } Ghost.prototype.freeColor = new THREE.Color(COLORS.GHOST) Ghost.prototype.minoesPosition = [ [P(0, 0, 0), P(0, 0, 0), P(0, 0, 0), P(0, 0, 0)], ] Ghost.prototype.offset = 0 class I extends Tetromino { } I.prototype.minoesPosition = [ [P(-1, 0), P(0, 0), P(1, 0), P(2, 0)], [P(1, 1), P(1, 0), P(1, -1), P(1, -2)], [P(-1, -1), P(0, -1), P(1, -1), P(2, -1)], [P(0, 1), P(0, 0), P(0, -1), P(0, -2)], ] I.prototype.srs = [ { [ROTATION.CW]: [P(0, 0), P(-2, 0), P(1, 0), P(-2, -1), P(1, 2)], [ROTATION.CCW]: [P(0, 0), P(-1, 0), P(2, 0), P(-1, 2), P(2, -1)] }, { [ROTATION.CW]: [P(0, 0), P(-1, 0), P(2, 0), P(-1, 2), P(2, -1)], [ROTATION.CCW]: [P(0, 0), P(2, 0), P(-1, 0), P(2, 1), P(-1, -2)] }, { [ROTATION.CW]: [P(0, 0), P(2, 0), P(-1, 0), P(2, 1), P(-1, -2)], [ROTATION.CCW]: [P(0, 0), P(1, 0), P(-2, 0), P(1, -2), P(-2, 1)] }, { [ROTATION.CW]: [P(0, 0), P(1, 0), P(-2, 0), P(1, -2), P(-2, 1)], [ROTATION.CCW]: [P(0, 0), P(-2, 0), P(1, 0), P(-2, -1), P(1, 2)] }, ] I.prototype.freeColor = new THREE.Color(COLORS.I) I.prototype.offset = 1 class J extends Tetromino { } J.prototype.minoesPosition = [ [P(-1, 1), P(-1, 0), P(0, 0), P(1, 0)], [P(0, 1), P(1, 1), P(0, 0), P(0, -1)], [P(1, -1), P(-1, 0), P(0, 0), P(1, 0)], [P(0, 1), P(-1, -1), P(0, 0), P(0, -1)], ] J.prototype.freeColor = new THREE.Color(COLORS.J) J.prototype.offset = 2 class L extends Tetromino { } L.prototype.minoesPosition = [ [P(-1, 0), P(0, 0), P(1, 0), P(1, 1)], [P(0, 1), P(0, 0), P(0, -1), P(1, -1)], [P(-1, 0), P(0, 0), P(1, 0), P(-1, -1)], [P(0, 1), P(0, 0), P(0, -1), P(-1, 1)], ] L.prototype.freeColor = new THREE.Color(COLORS.L) L.prototype.offset = 3 class O extends Tetromino { } O.prototype.minoesPosition = [ [P(0, 0), P(1, 0), P(0, 1), P(1, 1)] ] O.prototype.srs = [ { [ROTATION.CW]: [], [ROTATION.CCW]: [] } ] O.prototype.freeColor = new THREE.Color(COLORS.O) O.prototype.offset = 4 class S extends Tetromino { } S.prototype.minoesPosition = [ [P(-1, 0), P(0, 0), P(0, 1), P(1, 1)], [P(0, 1), P(0, 0), P(1, 0), P(1, -1)], [P(-1, -1), P(0, 0), P(1, 0), P(0, -1)], [P(-1, 1), P(0, 0), P(-1, 0), P(0, -1)], ] S.prototype.freeColor = new THREE.Color(COLORS.S) S.prototype.offset = 5 class T extends Tetromino { get tSpin() { if (this.rotatedLast) { let [a, b, c, d] = this.tSlots[this.facing] .map(p => !this.parent.cellIsEmpty(p.clone().add(this.position))) if (a && b && (c || d)) return T_SPIN.T_SPIN else if (c && d && (a || b)) return this.rotationPoint4Used ? T_SPIN.T_SPIN : T_SPIN.MINI } return T_SPIN.NONE } } T.prototype.minoesPosition = [ [P(-1, 0), P(0, 0), P(1, 0), P(0, 1)], [P(0, 1), P(0, 0), P(1, 0), P(0, -1)], [P(-1, 0), P(0, 0), P(1, 0), P(0, -1)], [P(0, 1), P(0, 0), P(0, -1), P(-1, 0)], ] T.prototype.tSlots = [ [P(-1, 1), P(1, 1), P(1, -1), P(-1, -1)], [P(1, 1), P(1, -1), P(-1, -1), P(-1, 1)], [P(1, -1), P(-1, -1), P(-1, 1), P(1, 1)], [P(-1, -1), P(-1, 1), P(1, 1), P(1, -1)], ] T.prototype.freeColor = new THREE.Color(COLORS.T) T.prototype.offset = 6 class Z extends Tetromino { } Z.prototype.minoesPosition = [ [P(-1, 1), P(0, 1), P(0, 0), P(1, 0)], [P(1, 1), P(1, 0), P(0, 0), P(0, -1)], [P(-1, 0), P(0, 0), P(0, -1), P(1, -1)], [P(0, 1), P(-1, 0), P(0, 0), P(-1, -1)] ] Z.prototype.freeColor = new THREE.Color(COLORS.Z) Z.prototype.offset = 7 class Playfield extends THREE.Group { constructor(loadingManager) { super() //this.visible = false const edgeMaterial = new THREE.MeshStandardMaterial({ color: COLORS.EDGE, envMap: environment, transparent: true, opacity: 0.3, roughness: 0.1, metalness: 0.67, }) const edgeShape = new THREE.Shape() .moveTo(-.3, SKYLINE) .lineTo(0, SKYLINE) .lineTo(0, 0) .lineTo(COLUMNS, 0) .lineTo(COLUMNS, SKYLINE) .lineTo(COLUMNS + .3, SKYLINE) .lineTo(COLUMNS + .3, -.3) .lineTo(-.3, -.3) .moveTo(-.3, SKYLINE) this.edge = new THREE.Mesh( new THREE.ExtrudeGeometry(edgeShape, { depth: 1, bevelEnabled: false, }), edgeMaterial ) this.add(this.edge) const retroEdgeShape = new THREE.Shape() .moveTo(-1, SKYLINE) .lineTo(0, SKYLINE) .lineTo(0, 0) .lineTo(COLUMNS, 0) .lineTo(COLUMNS, SKYLINE) .lineTo(COLUMNS + 1, SKYLINE) .lineTo(COLUMNS + 1, -.5) .lineTo(-1, -.5) .moveTo(-1, SKYLINE) const retroEdgeTexture = new THREE.TextureLoader(loadingManager).load("images/edge.png", (texture) => { texture.wrapS = THREE.RepeatWrapping texture.wrapT = THREE.RepeatWrapping }) const retroEdgeMaterial = new THREE.MeshStandardMaterial({ color: 0xd0d4c1, map: retroEdgeTexture, bumpMap: retroEdgeTexture, bumpScale: 0.3, roughness: 0.25, metalness: 0.8, }) this.retroEdge = new THREE.Mesh( new THREE.ExtrudeGeometry(retroEdgeShape, { depth: 1, bevelEnabled: false, }), [retroEdgeMaterial, sideMaterial, sideMaterial, sideMaterial, sideMaterial, sideMaterial], ) const back = new THREE.Mesh( new THREE.PlaneGeometry(COLUMNS, SKYLINE), new THREE.MeshStandardMaterial({ color: 0xc5d0a1, roughness: 0.9, metalness: 0.9, }) ) back.position.set(COLUMNS/2, SKYLINE/2, 0) this.retroEdge.add(back) this.retroEdge.visible = false this.add(this.retroEdge) const positionKF = new THREE.VectorKeyframeTrack('.position', [0, 1, 2], [0, 0, 0, 0, -0.2, 0, 0, 0, 0]) const clip = new THREE.AnimationClip('HardDrop', 3, [positionKF]) const animationGroup = new THREE.AnimationObjectGroup() animationGroup.add(this) this.mixer = new THREE.AnimationMixer(animationGroup) this.hardDropAnimation = this.mixer.clipAction(clip) this.hardDropAnimation.loop = THREE.LoopOnce this.hardDropAnimation.setDuration(0.2) this.freedMinoes = new Set() } init() { this.cells = Array(ROWS).fill().map(() => Array(COLUMNS)) if (this.piece) this.remove(this.piece) this.piece = undefined this.ghost = new Ghost() this.ghost.visible = false this.add(this.ghost) // this.visible = true } cellIsEmpty(p) { return 0 <= p.x && p.x < COLUMNS && 0 <= p.y && p.y < ROWS && !this.cells[p.y][p.x] } set piece(piece) { if (piece) { this.remove(this.piece) this.add(piece) piece.position.set(4, SKYLINE) this.ghost.copy(piece) } this._piece = piece } get piece() { return this._piece } lock() { this.piece.locking = false let minoes = Array.from(this.piece.children) minoes.forEach(mino => { this.add(mino) mino.position.add(this.piece.position) }) if (minoes.every(mino => mino.position.y >= SKYLINE)) return false return minoes.every(mino => { if (this.cellIsEmpty(mino.position)) { this.cells[mino.position.y][mino.position.x] = mino return true } else { return false } }) } clearLines() { let nbClearedLines = this.cells.reduceRight((nbClearedLines, row, y) => { if (row.filter(color => color).length == COLUMNS) { row.forEach(mino => this.freedMinoes.add(mino)) this.cells.splice(y, 1) this.cells.push(Array(COLUMNS)) return ++nbClearedLines } return nbClearedLines }, 0) if (nbClearedLines) this.cells.forEach((row, y) => row.forEach((mino, x) => mino.position.set(x, y, 0))) return nbClearedLines } updateFreedMinoes(delta) { this.freedMinoes.forEach(mino => { if (mino.explode(delta)) { this.remove(mino) this.freedMinoes.delete(mino) } }) } update(delta) { this.updateFreedMinoes(delta) this.mixer?.update(delta) } } class HoldQueue extends THREE.Group { constructor() { super() this.position.set(-4, SKYLINE - 2) } set piece(piece) { if(piece) { this.remove(this.piece) piece.holdEnabled = false piece.locking = false piece.position.set(0, 0) piece.facing = FACING.NORTH this.add(piece) } this._piece = piece } get piece() { return this._piece } } class NextQueue extends THREE.Group { constructor() { super() this.position.set(13, SKYLINE - 2) } init() { this.clear() this.positions.forEach(position => this.add(new Tetromino.random(position))) } shift() { let fistPiece = this.children.shift() this.remove(fistPiece) this.add(new Tetromino.random()) this.positions.forEach((position, i) => this.children[i].position.copy(position)) return fistPiece } } NextQueue.prototype.positions = [P(0, 0), P(0, -3), P(0, -6), P(0, -9), P(0, -12), P(0, -15), P(0, -18)] export { T_SPIN, FACING, TRANSLATION, ROTATION, COLORS, environment, Mino, Tetromino, Playfield, HoldQueue, NextQueue }