Compare commits

...

11 Commits

Author SHA1 Message Date
c52a604f0f add README 2025-05-21 10:21:03 +02:00
8e9a089d34 meta 2025-05-20 17:01:59 +02:00
d5893eb8ef fix changeKey 2025-04-08 00:31:52 +02:00
1e006d46b9 little more bumpScale 2024-10-05 15:23:18 +02:00
0f84f90e05 tweak space theme 2024-10-05 15:22:15 +02:00
4287edab71 rename TetraControls 2024-10-03 22:26:37 +02:00
5c2eaca35a remove import 2024-10-03 00:38:10 +02:00
e7dc780173 T class 2024-10-03 00:37:53 +02:00
9721b311eb move mino materials to InstancedMino.prototype 2024-10-03 00:31:12 +02:00
d3f6cf9b71 refactoring 2024-10-03 00:18:42 +02:00
5b058a58b3 load mino retro texture if needed 2024-10-02 22:38:37 +02:00
8 changed files with 157 additions and 163 deletions

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# teTra
Falling blocks web game made with three.js librairy
![screenshot](https://git.malingrey.fr/adrien/teTra/raw/branch/master/thumbnail.png)

15
app.js
View File

@ -1,10 +1,10 @@
import * as THREE from 'three' import * as THREE from 'three'
import { scheduler } from './jsm/scheduler.js' import { scheduler } from './jsm/scheduler.js'
import { TRANSLATION, ROTATION, environment, Mino, Playfield, HoldQueue, NextQueue } from './jsm/Tetrominoes.js' import { TRANSLATION, ROTATION, environment, InstancedMino, Mino, Playfield, HoldQueue, NextQueue } from './jsm/Tetrominoes.js'
import Settings from './jsm/Settings.js' import Settings from './jsm/Settings.js'
import { Stats } from './jsm/Stats.js' import { Stats } from './jsm/Stats.js'
import { Menu } from './jsm/Menu.js' import { Menu } from './jsm/Menu.js'
import TetraControls from './jsm/TetraControls.js' import CameraControls from './jsm/CameraControls.js'
import { TetraScene } from './jsm/TetraScene.js' import { TetraScene } from './jsm/TetraScene.js'
import * as FPS from 'three/addons/libs/stats.module.js' import * as FPS from 'three/addons/libs/stats.module.js'
@ -30,7 +30,7 @@ let game = {
menu.stats.show() menu.stats.show()
menu.settings.close() menu.settings.close()
Mino.meshes.clear() Mino.instances.clear()
nextQueue.init() nextQueue.init()
holdQueue.piece = undefined holdQueue.piece = undefined
@ -297,9 +297,10 @@ loadingManager.onStart = function (url, itemsLoaded, itemsTotal) {
const stats = new Stats() const stats = new Stats()
const settings = new Settings() const settings = new Settings()
const scene = new TetraScene(settings, loadingManager) const scene = new TetraScene(settings, loadingManager)
const controls = new TetraControls(scene.camera, renderer.domElement) const controls = new CameraControls(scene.camera, renderer.domElement)
scene.add(Mino.meshes) const minoes = new InstancedMino()
scene.add(minoes)
const holdQueue = new HoldQueue() const holdQueue = new HoldQueue()
scene.add(holdQueue) scene.add(holdQueue)
const playfield = new Playfield(loadingManager) const playfield = new Playfield(loadingManager)
@ -307,7 +308,7 @@ scene.add(playfield)
const nextQueue = new NextQueue() const nextQueue = new NextQueue()
scene.add(nextQueue) scene.add(nextQueue)
const menu = new Menu(game, settings, stats, scene, controls, playfield) const menu = new Menu(game, settings, stats, scene, minoes, playfield)
menu.load() menu.load()
let fps let fps
@ -328,7 +329,7 @@ function animate() {
scene.updateMatrixWorld() scene.updateMatrixWorld()
scene.update(delta) scene.update(delta)
playfield.update(delta) playfield.update(delta)
Mino.meshes.update() minoes.update()
controls.update() controls.update()
renderer.render(scene, scene.camera) renderer.render(scene, scene.camera)

View File

@ -48,10 +48,10 @@
transform-style: preserve-3d; transform-style: preserve-3d;
} }
.first.mino { top: -0.5em; left: -1em; } .T.tetromino .first.mino { top: -0.5em; left: -1em; }
.second.mino { top: -0.5em; left: 0em; } .T.tetromino .second.mino { top: -0.5em; left: 0em; }
.third.mino { top: -0.5em; left: 1em; } .T.tetromino .third.mino { top: -0.5em; left: 1em; }
.fourth.mino { top: 0.5em; left: 0em; } .T.tetromino .fourth.mino { top: 0.5em; left: 0em; }
.face { .face {
position: absolute; position: absolute;

View File

@ -8,6 +8,15 @@
<link rel="icon" href="favicon.ico"> <link rel="icon" href="favicon.ico">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/loading.css"> <link rel="stylesheet" href="css/loading.css">
<meta property="og:title" content="ᵀᴱTᴿᴬ"/>
<meta property="og:type" content="game"/>
<meta property="og:url" content="https://adrien.malingrey.fr/jeux/tetra/"/>
<meta property="og:image" content="https://adrien.malingrey.fr/jeux/tetra/thumbnail.png"/>
<meta property="og:image:width" content="250"/>
<meta property="og:image:height" content="250"/>
<meta property="og:description" content="Des blocs qui tombent en 3D"/>
<meta property="og:locale" content="fr_FR"/>
<meta property="og:site_name" content="adrien.malingrey.fr"/>
<script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script> <script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
<script type="importmap"> <script type="importmap">
{ {
@ -22,7 +31,7 @@
<body> <body>
<span id="loadingDiv"> <span id="loadingDiv">
<div class="scene"> <div class="scene">
<div class="tetromino"> <div class="T tetromino">
<div class="first mino"> <div class="first mino">
<div class="front face"></div> <div class="front face"></div>
<div class="back face"></div> <div class="back face"></div>

View File

@ -1,7 +1,7 @@
import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
export default class TetraControls extends OrbitControls { export default class CameraControls extends OrbitControls {
constructor(camera, domElement) { constructor(camera, domElement) {
super(camera, domElement) super(camera, domElement)
this.autoRotate this.autoRotate

View File

@ -1,10 +1,10 @@
import * as THREE from 'three' import * as THREE from 'three'
import { GUI } from 'three/addons/libs/lil-gui.module.min.js' import { GUI } from 'three/addons/libs/lil-gui.module.min.js'
import { Mino, environment } from './Tetrominoes.js' import { environment } from './Tetrominoes.js'
export class Menu extends GUI { export class Menu extends GUI {
constructor(game, settings, stats, scene, controls, playfield) { constructor(game, settings, stats, scene, minoes, playfield) {
super({title: "ᵀᴱTᴿᴬ"}) super({title: "ᵀᴱTᴿᴬ"})
this.startButton = this.add(game, "start").name("Jouer").hide() this.startButton = this.add(game, "start").name("Jouer").hide()
@ -28,7 +28,7 @@ export class Menu extends GUI {
this.settings.add(settings, "theme", ["Plasma", "Espace", "Rétro"]).name("Thème").onChange(theme => { this.settings.add(settings, "theme", ["Plasma", "Espace", "Rétro"]).name("Thème").onChange(theme => {
scene.theme = theme scene.theme = theme
Mino.meshes.theme = theme minoes.theme = theme
if (theme == "Rétro") { if (theme == "Rétro") {
playfield.edge.visible = false playfield.edge.visible = false
playfield.retroEdge.visible = true playfield.retroEdge.visible = true
@ -43,21 +43,21 @@ export class Menu extends GUI {
this.settings.key = this.settings.addFolder("Commandes").open() this.settings.key = this.settings.addFolder("Commandes").open()
let moveLeftKeyController = this.settings.key.add(settings.key, "moveLeft").name('Gauche') let moveLeftKeyController = this.settings.key.add(settings.key, "moveLeft").name('Gauche')
moveLeftKeyController.domElement.onclick = this.changeKey.bind(moveLeftKeyController) moveLeftKeyController.domElement.onclick = this.changeKey(moveLeftKeyController)
let moveRightKeyController = this.settings.key.add(settings.key, "moveRight").name('Droite') let moveRightKeyController = this.settings.key.add(settings.key, "moveRight").name('Droite')
moveRightKeyController.domElement.onclick = this.changeKey.bind(moveRightKeyController) moveRightKeyController.domElement.onclick = this.changeKey(moveRightKeyController)
let rotateCWKeyController = this.settings.key.add(settings.key, "rotateCW").name('Rotation horaire') let rotateCWKeyController = this.settings.key.add(settings.key, "rotateCW").name('Rotation horaire')
rotateCWKeyController.domElement.onclick = this.changeKey.bind(rotateCWKeyController) rotateCWKeyController.domElement.onclick = this.changeKey(rotateCWKeyController)
let rotateCCWKeyController = this.settings.key.add(settings.key, "rotateCCW").name('anti-horaire') let rotateCCWKeyController = this.settings.key.add(settings.key, "rotateCCW").name('anti-horaire')
rotateCCWKeyController.domElement.onclick = this.changeKey.bind(rotateCCWKeyController) rotateCCWKeyController.domElement.onclick = this.changeKey(rotateCCWKeyController)
let softDropKeyController = this.settings.key.add(settings.key, "softDrop").name('Chute lente') let softDropKeyController = this.settings.key.add(settings.key, "softDrop").name('Chute lente')
softDropKeyController.domElement.onclick = this.changeKey.bind(softDropKeyController) softDropKeyController.domElement.onclick = this.changeKey(softDropKeyController)
let hardDropKeyController = this.settings.key.add(settings.key, "hardDrop").name('Chute rapide') let hardDropKeyController = this.settings.key.add(settings.key, "hardDrop").name('Chute rapide')
hardDropKeyController.domElement.onclick = this.changeKey.bind(hardDropKeyController) hardDropKeyController.domElement.onclick = this.changeKey(hardDropKeyController)
let holdKeyController = this.settings.key.add(settings.key, "hold").name('Garder') let holdKeyController = this.settings.key.add(settings.key, "hold").name('Garder')
holdKeyController.domElement.onclick = this.changeKey.bind(holdKeyController) holdKeyController.domElement.onclick = this.changeKey(holdKeyController)
let pauseKeyController = this.settings.key.add(settings.key, "pause").name('Pause') let pauseKeyController = this.settings.key.add(settings.key, "pause").name('Pause')
pauseKeyController.domElement.onclick = this.changeKey.bind(pauseKeyController) pauseKeyController.domElement.onclick = this.changeKey(pauseKeyController)
this.settings.delay = this.settings.addFolder("Répétition automatique").open() this.settings.delay = this.settings.addFolder("Répétition automatique").open()
this.settings.delay.add(settings,"arrDelay").name("ARR (ms)").min(2).max(200).step(1); this.settings.delay.add(settings,"arrDelay").name("ARR (ms)").min(2).max(200).step(1);
@ -77,10 +77,10 @@ export class Menu extends GUI {
function changeMaterial() { function changeMaterial() {
material?.destroy() material?.destroy()
material = dev.addFolder("minoes material") material = dev.addFolder("minoes material")
material.add(Mino.meshes.material, "constructor", ["MeshBasicMaterial", "MeshStandardMaterial", "MeshPhysicalMaterial"]).listen().onChange(type => { material.add(minoes.material, "constructor", ["MeshBasicMaterial", "MeshStandardMaterial", "MeshPhysicalMaterial"]).listen().onChange(type => {
switch(type) { switch(type) {
case "MeshBasicMaterial": case "MeshBasicMaterial":
Mino.meshes.material = new THREE.MeshBasicMaterial({ minoes.material = new THREE.MeshBasicMaterial({
envMap: environment, envMap: environment,
side: THREE.DoubleSide, side: THREE.DoubleSide,
transparent: true, transparent: true,
@ -89,7 +89,7 @@ export class Menu extends GUI {
}) })
break break
case "MeshStandardMaterial": case "MeshStandardMaterial":
Mino.meshes.material = new THREE.MeshStandardMaterial({ minoes.material = new THREE.MeshStandardMaterial({
envMap: environment, envMap: environment,
side: THREE.DoubleSide, side: THREE.DoubleSide,
transparent: true, transparent: true,
@ -99,7 +99,7 @@ export class Menu extends GUI {
}) })
break break
case "MeshPhysicalMaterial": case "MeshPhysicalMaterial":
Mino.meshes.material = new THREE.MeshPhysicalMaterial({ minoes.material = new THREE.MeshPhysicalMaterial({
envMap: environment, envMap: environment,
side: THREE.DoubleSide, side: THREE.DoubleSide,
transparent: true, transparent: true,
@ -111,12 +111,11 @@ export class Menu extends GUI {
}) })
break break
} }
Mino.meshes.update = Mino.meshes.updateColor minoes.update = minoes.updateColor
changeMaterial() changeMaterial()
}) })
console.log("lnlnl")
let minoMaterial = Mino.meshes.material instanceof Array ? Mino.meshes.material[0] : Mino.meshes.material let minoMaterial = minoes.material instanceof Array ? minoes.material[0] : minoes.material
if ("opacity" in minoMaterial) material.add(minoMaterial, "opacity" ).min(0).max(1) if ("opacity" in minoMaterial) material.add(minoMaterial, "opacity" ).min(0).max(1)
if ("reflectivity" in minoMaterial) material.add(minoMaterial, "reflectivity" ).min(0).max(1) if ("reflectivity" in minoMaterial) material.add(minoMaterial, "reflectivity" ).min(0).max(1)
if ("roughness" in minoMaterial) material.add(minoMaterial, "roughness" ).min(0).max(1) if ("roughness" in minoMaterial) material.add(minoMaterial, "roughness" ).min(0).max(1)
@ -153,7 +152,7 @@ export class Menu extends GUI {
vortex.add(scene.vortex.darkCylinder.material, "opacity").name("dark").min(0).max(1) vortex.add(scene.vortex.darkCylinder.material, "opacity").name("dark").min(0).max(1)
vortex.add(scene.vortex.colorFullCylinder.material, "opacity").name("colorFull").min(0).max(1) vortex.add(scene.vortex.colorFullCylinder.material, "opacity").name("colorFull").min(0).max(1)
changeMaterial(Mino.meshes.material.constructor.name) changeMaterial(minoes.material.constructor.name)
material.close() material.close()
} }
} }
@ -168,8 +167,8 @@ export class Menu extends GUI {
localStorage["teTraSettings"] = JSON.stringify(this.settings.save()) localStorage["teTraSettings"] = JSON.stringify(this.settings.save())
} }
changeKey() { changeKey(settings) {
let controller = this.settings let controller = settings
let input = controller.domElement.getElementsByTagName("input")[0] let input = controller.domElement.getElementsByTagName("input")[0]
input.select() input.select()
input.onkeydown = function (event) { input.onkeydown = function (event) {

View File

@ -69,129 +69,8 @@ const sideMaterial = new THREE.MeshStandardMaterial({
}) })
class InstancedMino extends THREE.InstancedMesh { export class InstancedMino extends THREE.InstancedMesh {
constructor(geometry, material, count) { constructor() {
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()
}
set theme(theme) {
this._theme = theme
this.material = Mino.materials[theme]
if (theme == "Rétro") {
this.resetColor()
this.update = this.updateOffset
} else {
this.update = this.updateColor
}
}
get theme() {
return this._theme
}
setOffsetAt(index, offset) {
this.offsets[2*index] = offset.x
this.offsets[2*index + 1] = offset.y
}
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.7,
roughness: 0.6,
metalness: 1,
}),
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: COLORS.RETRO,
map: texture,
bumpMap: texture,
bumpScale: 1.5,
roughness: 0.25,
metalness: 0.9,
transparent: true,
}, 8, 8)
})
new THREE.TextureLoader().load("images/edges.png", (texture) => {
this.materials.Rétro[1] = this.materials.Rétro[3] =this.materials.Rétro[4] = this.materials.Rétro[4] = new TileMaterial({
color: COLORS.RETRO,
map: texture,
bumpMap: texture,
bumpScale: 1.5,
roughness: 0.25,
metalness: 0.9,
transparent: true,
}, 1, 1)
})
}
static meshes
static {
let minoFaceShape = new THREE.Shape() let minoFaceShape = new THREE.Shape()
minoFaceShape.moveTo(.1, .1) minoFaceShape.moveTo(.1, .1)
minoFaceShape.lineTo(.1, .9) minoFaceShape.lineTo(.1, .9)
@ -208,9 +87,110 @@ class Mino extends THREE.Object3D {
bevelSegments: 1 bevelSegments: 1
} }
const geometry = new THREE.ExtrudeGeometry(minoFaceShape, minoExtrudeSettings) const geometry = new THREE.ExtrudeGeometry(minoFaceShape, minoExtrudeSettings)
this.meshes = new InstancedMino(geometry, this.materials.Plasma, 2*ROWS*COLUMNS) super(geometry, undefined, 2*ROWS*COLUMNS)
this.offsets = new Uint8Array(2*this.count)
} }
set theme(theme) {
if (theme == "Rétro") {
this.resetColor()
this.update = this.updateOffset
if (this.materials["Rétro"]) {
this.material = this.materials["Rétro"]
} else {
this.materials["Rétro"] = []
const loadingManager = new THREE.LoadingManager(() => this.material = this.materials["Rétro"])
new THREE.TextureLoader(loadingManager).load("images/sprites.png", (texture) => {
this.materials.Rétro[0] = this.materials.Rétro[2] = new TileMaterial({
color: COLORS.RETRO,
map: texture,
bumpMap: texture,
bumpScale: 1.5,
roughness: 0.25,
metalness: 0.9,
transparent: true,
}, 8, 8)
})
new THREE.TextureLoader(loadingManager).load("images/edges.png", (texture) => {
this.materials.Rétro[1] = this.materials.Rétro[3] = this.materials.Rétro[4] = this.materials.Rétro[5] = new TileMaterial({
color: COLORS.RETRO,
map: texture,
bumpMap: texture,
bumpScale: 1.5,
roughness: 0.25,
metalness: 0.9,
transparent: true,
}, 1, 1)
})
}
} else {
this.update = this.updateColor
this.material = this.materials[theme]
}
}
setOffsetAt(index, offset) {
this.offsets[2*index] = offset.x
this.offsets[2*index + 1] = offset.y
}
resetColor() {
this.instanceColor = null
}
updateColor() {
this.count = 0
Mino.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
Mino.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))
}
}
}
InstancedMino.prototype.materials = {
Plasma: new THREE.MeshStandardMaterial({
envMap: environment,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.7,
roughness: 0.6,
metalness: 1,
}),
Espace: new THREE.MeshStandardMaterial({
envMap: environment,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.8,
roughness: 0.1,
metalness: 0.99,
})
}
class Mino extends THREE.Object3D {
static instances = new Set()
constructor(color, offset) { constructor(color, offset) {
super() super()
this.color = color this.color = color
@ -218,7 +198,7 @@ class Mino extends THREE.Object3D {
this.velocity = P(50 - 100 * Math.random(), 60 - 100 * Math.random(), 50 - 100 * Math.random()) this.velocity = P(50 - 100 * Math.random(), 60 - 100 * Math.random(), 50 - 100 * Math.random())
this.rotationAngle = P(Math.random(), Math.random(), Math.random()).normalize() this.rotationAngle = P(Math.random(), Math.random(), Math.random()).normalize()
this.angularVelocity = 5 - 10 * Math.random() this.angularVelocity = 5 - 10 * Math.random()
this.constructor.meshes.add(this) this.constructor.instances.add(this)
} }
explode(delta) { explode(delta) {
@ -234,7 +214,7 @@ class Mino extends THREE.Object3D {
} }
dispose() { dispose() {
this.constructor.meshes.delete(this) this.constructor.instances.delete(this)
} }
} }

View File

@ -70,10 +70,10 @@ export class Vortex extends THREE.Group {
new THREE.TextureLoader(this.loadingManager).load("./images/dark.jpg", texture => { new THREE.TextureLoader(this.loadingManager).load("./images/dark.jpg", texture => {
texture.wrapS = THREE.RepeatWrapping texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.MirroredRepeatWrapping texture.wrapT = THREE.MirroredRepeatWrapping
texture.repeat.set(2, 2) texture.repeat.set(2, 4)
this.darkCylinder.material.map = texture this.darkCylinder.material.map = texture
}) })
this.darkCylinder.material.opacity = 0.05 this.darkCylinder.material.opacity = 0.08
new THREE.TextureLoader(this.loadingManager).load("./images/colorfull.jpg", texture => { new THREE.TextureLoader(this.loadingManager).load("./images/colorfull.jpg", texture => {
texture.wrapS = THREE.RepeatWrapping texture.wrapS = THREE.RepeatWrapping
@ -81,7 +81,7 @@ export class Vortex extends THREE.Group {
texture.repeat.set(2, 2) texture.repeat.set(2, 2)
this.colorFullCylinder.material.map = texture this.colorFullCylinder.material.map = texture
}) })
this.colorFullCylinder.material.opacity = 0.14 this.colorFullCylinder.material.opacity = 0.15
this.globalRotation = 0.028 this.globalRotation = 0.028
this.darkTextureRotation = 0.006 this.darkTextureRotation = 0.006