From a15dfff1f80a26ef30b411a39fde4ea9b15bf329 Mon Sep 17 00:00:00 2001 From: phisn Date: Tue, 7 May 2024 23:52:45 +0200 Subject: [PATCH] Finish ppo with convolution prototype --- packages/learning-gym/src/game-environment.ts | 246 ++++++++++++++++++ packages/learning-gym/src/main.ts | 229 +--------------- .../src/module-scene/module-scene.ts | 62 ----- .../module-scene/mutatable-shape-geometry.ts | 73 ------ .../src/module-scene/objects/flag.ts | 39 --- .../src/module-scene/objects/rocket.ts | 33 --- packages/learning/package.json | 2 +- packages/learning/src/main.ts | 214 +++++++++++---- packages/learning/src/ppo/ppo.ts | 11 +- .../runtime/src/core/level/level-factory.ts | 2 - packages/runtime/src/runtime.ts | 2 - .../web-game/src/game/game-agent-wrapper.ts | 14 +- .../game/modules/module-scene-agent/colors.ts | 20 ++ .../module-scene-agent/module-scene-agent.ts | 17 +- .../module-scene-agent/objects/flag.ts | 5 +- .../module-scene-agent/objects/rocket.ts | 8 +- packages/web/package.json | 1 + packages/web/src/app/campaign/World.tsx | 45 ++-- packages/web/src/app/layout/Layout.tsx | 8 +- packages/web/tailwind.better-containers.cjs | 98 +++++++ packages/web/tailwind.config.cjs | 9 +- yarn.lock | 11 +- 22 files changed, 620 insertions(+), 529 deletions(-) create mode 100644 packages/learning-gym/src/game-environment.ts delete mode 100644 packages/learning-gym/src/module-scene/module-scene.ts delete mode 100644 packages/learning-gym/src/module-scene/mutatable-shape-geometry.ts delete mode 100644 packages/learning-gym/src/module-scene/objects/flag.ts delete mode 100644 packages/learning-gym/src/module-scene/objects/rocket.ts create mode 100644 packages/web-game/src/game/modules/module-scene-agent/colors.ts create mode 100644 packages/web/tailwind.better-containers.cjs diff --git a/packages/learning-gym/src/game-environment.ts b/packages/learning-gym/src/game-environment.ts new file mode 100644 index 00000000..29a69848 --- /dev/null +++ b/packages/learning-gym/src/game-environment.ts @@ -0,0 +1,246 @@ +import RAPIER from "custom-rapier2d-node/rapier" +import * as gl from "gl" +import { PNG } from "pngjs" +import { EntityWith, MessageCollector } from "runtime-framework" +import { WorldModel } from "runtime/proto/world" +import { LevelCapturedMessage } from "runtime/src/core/level-capture/level-captured-message" +import { RuntimeComponents } from "runtime/src/core/runtime-components" +import { RuntimeSystemContext } from "runtime/src/core/runtime-system-stack" +import { Runtime, newRuntime } from "runtime/src/runtime" +import * as THREE from "three" +import { GameAgentWrapper } from "web-game/src/game/game-agent-wrapper" +import { Reward, RewardFactory } from "../../web-game/src/game/reward/default-reward" + +export interface GameEnvironmentConfig { + grayScale: boolean + size: number + pixelsPerUnit: number + stepsPerFrame: number +} + +export class GameEnvironment { + private observationImageBuffer: Buffer + private observationFeatureBuffer: Buffer + private imageBuffer: Buffer + private imageChannels: number + + private runtime!: Runtime + private reward!: Reward + private game!: GameAgentWrapper + private renderer: THREE.WebGLRenderer + + private rotation!: number + private rocket!: EntityWith + private targetFlag!: EntityWith + private capturedCollector!: MessageCollector + + private png: PNG + + constructor( + private world: WorldModel, + private gamemode: string[], + private config: GameEnvironmentConfig, + private rewardFactory: RewardFactory, + ) { + this.imageChannels = config.grayScale ? 1 : 3 + + // features (4 bytes) + // - velocity x + // - velocity y + // - rotation + // - distance to flag x + // - distance to flag y + // - flag in capture + this.observationFeatureBuffer = Buffer.alloc(4 * 6) + + // image (3 channels) + this.observationImageBuffer = Buffer.alloc( + config.size * config.size * (config.grayScale ? 1 : 3), + ) + + // source image has additionally alpha channel + this.imageBuffer = Buffer.alloc(config.size * config.size * 4) + + this.png = new PNG({ + width: config.size, + height: config.size, + }) + + const canvas = { + width: config.size, + height: config.size, + addEventListener: () => {}, + removeEventListener: () => {}, + } + + this.renderer = new THREE.WebGLRenderer({ + canvas: canvas as any, + antialias: false, + powerPreference: "high-performance", + context: gl.default(config.size, config.size, { + preserveDrawingBuffer: true, + }), + depth: false, + }) + + const renderTarget = new THREE.WebGLRenderTarget(config.size, config.size) + this.renderer.setRenderTarget(renderTarget) + + this.reset() + } + + reset(): [Buffer, Buffer] { + this.runtime = newRuntime( + RAPIER as any, + this.world, + this.gamemode[Math.floor(Math.random() * this.gamemode.length)], + ) + + this.game = new GameAgentWrapper( + this.runtime, + new THREE.Scene() as any, + this.config.grayScale, + (0.5 * this.config.size) / this.config.pixelsPerUnit, + ) + + this.rocket = this.runtime.factoryContext.store.find("rocket", "rigidBody")[0] + this.capturedCollector = this.runtime.factoryContext.messageStore.collect("levelCaptured") + this.targetFlag = nextFlag(this.runtime, this.rocket) + this.rotation = 0 + this.reward = this.rewardFactory(this.runtime) + + this.extractPixelsToObservationBuffer() + this.prepareFeatureBuffer() + + return [this.observationImageBuffer, this.observationFeatureBuffer] + } + + step(action: Buffer): [number, boolean, Buffer, Buffer] { + const input = this.stepWithActionToInput(action.readInt8(0)) + + const [reward, done] = this.reward.next(() => { + for (let i = 0; i < this.config.stepsPerFrame; ++i) { + this.game.step(input) + } + }) + + this.renderer.render(this.game.sceneModule.getScene() as any, this.game.camera as any) + + this.extractPixelsToObservationBuffer() + this.prepareFeatureBuffer() + + return [reward, done, this.observationImageBuffer, this.observationFeatureBuffer] + } + + stepWithActionToInput(action: number): RuntimeSystemContext { + switch (action) { + case 0: + return { thrust: false, rotation: this.rotation } + case 1: + this.rotation += 0.1 + return { thrust: false, rotation: this.rotation } + case 2: + this.rotation -= 0.1 + return { thrust: false, rotation: this.rotation } + case 3: + return { thrust: true, rotation: this.rotation } + case 4: + this.rotation += 0.1 + return { thrust: true, rotation: this.rotation } + case 5: + this.rotation -= 0.1 + return { thrust: true, rotation: this.rotation } + default: + throw new Error(`Invalid action: ${action}`) + } + } + + extractPixelsToObservationBuffer() { + this.renderer + .getContext() + .readPixels( + 0, + 0, + this.renderer.getContext().drawingBufferWidth, + this.renderer.getContext().drawingBufferHeight, + this.renderer.getContext().RGBA, + this.renderer.getContext().UNSIGNED_BYTE, + this.imageBuffer, + ) + + // The framebuffer coordinate space has (0, 0) in the bottom left, whereas images usually + // have (0, 0) at the top left. Vertical flipping follows: + for (let row = 0; row < this.config.size; row += 1) { + for (let column = 0; column < this.config.size; column++) { + const index = ((this.config.size - row - 1) * this.config.size + column) * 4 + + if (this.config.grayScale) { + // we use a cheap grayscale conversion + const value = + this.imageBuffer[index] | + this.imageBuffer[index + 1] | + this.imageBuffer[index + 2] + + this.observationImageBuffer[row * this.config.size + column] = value + } else { + const targetIndex = (row * this.config.size + column) * 3 + + this.observationImageBuffer[targetIndex] = this.imageBuffer[index] + this.observationImageBuffer[targetIndex + 1] = this.imageBuffer[index + 1] + this.observationImageBuffer[targetIndex + 2] = this.imageBuffer[index + 2] + } + } + } + } + + prepareFeatureBuffer() { + for (const message of this.capturedCollector) { + this.targetFlag = nextFlag(this.runtime, this.rocket) + } + + const dx = + this.rocket.components.rigidBody.translation().x - + this.targetFlag.components.level.flag.x + const dy = + this.rocket.components.rigidBody.translation().y - + this.targetFlag.components.level.flag.y + + const inCapture = this.targetFlag.components.level.inCapture + + this.observationFeatureBuffer.writeFloatLE(this.rocket.components.rigidBody.linvel().x, 0) + this.observationFeatureBuffer.writeFloatLE(this.rocket.components.rigidBody.linvel().y, 4) + this.observationFeatureBuffer.writeFloatLE(this.rotation, 8) + this.observationFeatureBuffer.writeFloatLE(dx, 12) + this.observationFeatureBuffer.writeFloatLE(dy, 16) + this.observationFeatureBuffer.writeFloatLE(inCapture ? 1 : 0, 20) + } + + generatePng(): Buffer { + this.png.data.set(this.observationImageBuffer) + + return PNG.sync.write(this.png, { + inputColorType: this.config.grayScale ? 0 : 2, + inputHasAlpha: false, + }) + } +} + +function nextFlag(runtime: Runtime, rocket: EntityWith) { + const distanceToFlag = (flagEntity: EntityWith) => { + const dx = rocket.components.rigidBody.translation().x - flagEntity.components.level.flag.x + const dy = rocket.components.rigidBody.translation().y - flagEntity.components.level.flag.y + return Math.sqrt(dx * dx + dy * dy) + } + + const nextLevel = runtime.factoryContext.store + .find("level") + .filter(level => !level.components.level.captured) + .map(level => [level, distanceToFlag(level)] as const) + .reduce(([minLevel, minDistance], [level, distance]) => + distance < minDistance ? [level, distance] : [minLevel, minDistance], + )[0] + + return nextLevel +} + +global.navigator = { userAgent: "node" } as any diff --git a/packages/learning-gym/src/main.ts b/packages/learning-gym/src/main.ts index 930eac5e..f9f1e664 100644 --- a/packages/learning-gym/src/main.ts +++ b/packages/learning-gym/src/main.ts @@ -1,206 +1,8 @@ -import RAPIER from "custom-rapier2d-node/rapier" -import * as gl from "gl" -import { EntityWith, MessageCollector } from "runtime-framework" +import { writeFileSync } from "fs" import { WorldModel } from "runtime/proto/world" -import { LevelCapturedMessage } from "runtime/src/core/level-capture/level-captured-message" -import { RuntimeComponents } from "runtime/src/core/runtime-components" -import { RuntimeSystemContext } from "runtime/src/core/runtime-system-stack" -import { Runtime, newRuntime } from "runtime/src/runtime" -import * as THREE from "three" -import { GameAgentWrapper } from "web-game/src/game/game-agent-wrapper" -import { Reward, RewardFactory } from "../../web-game/src/game/reward/default-reward" +import { DefaultGameReward } from "web-game/src/game/reward/default-reward" +import { GameEnvironment } from "./game-environment" -export interface GameEnvironmentConfig { - width: number - height: number - stepsPerFrame: number -} - -export class GameEnvironment { - private observationImageBuffer: Buffer - private observationFeatureBuffer: Buffer - private imageBuffer: Buffer - - private runtime!: Runtime - private reward!: Reward - private game!: GameAgentWrapper - private renderer: THREE.WebGLRenderer - - private rotation!: number - private rocket!: EntityWith - private targetFlag!: EntityWith - private capturedCollector!: MessageCollector - - constructor( - private world: WorldModel, - private gamemode: string, - private config: GameEnvironmentConfig, - private rewardFactory: RewardFactory, - ) { - // features (4 bytes) - // - velocity x - // - velocity y - // - rotation - // - distance to flag x - // - distance to flag y - // - flag in capture - this.observationFeatureBuffer = Buffer.alloc(4 * 6) - - // image (3 channels) - this.observationImageBuffer = Buffer.alloc(config.width * config.height * 3) - - // source image has additionally alpha channel - this.imageBuffer = Buffer.alloc(config.width * config.height * 4) - - const canvas = { - width: config.width, - height: config.height, - addEventListener: () => {}, - removeEventListener: () => {}, - } - - this.renderer = new THREE.WebGLRenderer({ - canvas: canvas as any, - antialias: false, - powerPreference: "high-performance", - context: gl.default(config.width, config.height, { - preserveDrawingBuffer: true, - }), - depth: false, - }) - - const renderTarget = new THREE.WebGLRenderTarget(config.width, config.height) - this.renderer.setRenderTarget(renderTarget) - - this.reset() - } - - reset(): [Buffer, Buffer] { - this.runtime = newRuntime(RAPIER as any, this.world, this.gamemode) - this.game = new GameAgentWrapper(this.runtime, new THREE.Scene() as any) - this.rocket = this.runtime.factoryContext.store.find("rocket", "rigidBody")[0] - this.capturedCollector = this.runtime.factoryContext.messageStore.collect("levelCaptured") - this.targetFlag = nextFlag(this.runtime, this.rocket) - this.rotation = 0 - this.reward = this.rewardFactory(this.runtime) - - this.extractPixelsToObservationBuffer() - this.prepareFeatureBuffer() - - return [this.observationImageBuffer, this.observationFeatureBuffer] - } - - step(action: Buffer): [number, boolean, Buffer, Buffer] { - const input = this.stepWithActionToInput(action.readInt8(0)) - - const [reward, done] = this.reward.next(() => { - for (let i = 0; i < this.config.stepsPerFrame; ++i) { - this.game.step(input) - } - }) - - this.renderer.render(this.game.sceneModule.getScene() as any, this.game.camera as any) - - this.extractPixelsToObservationBuffer() - this.prepareFeatureBuffer() - - return [reward, done, this.observationImageBuffer, this.observationFeatureBuffer] - } - - stepWithActionToInput(action: number): RuntimeSystemContext { - switch (action) { - case 0: - return { thrust: false, rotation: this.rotation } - case 1: - this.rotation += 0.1 - return { thrust: false, rotation: this.rotation } - case 2: - this.rotation -= 0.1 - return { thrust: false, rotation: this.rotation } - case 3: - return { thrust: true, rotation: this.rotation } - case 4: - this.rotation += 0.1 - return { thrust: true, rotation: this.rotation } - case 5: - this.rotation -= 0.1 - return { thrust: true, rotation: this.rotation } - default: - throw new Error(`Invalid action: ${action}`) - } - } - - extractPixelsToObservationBuffer() { - this.renderer - .getContext() - .readPixels( - 0, - 0, - this.renderer.getContext().drawingBufferWidth, - this.renderer.getContext().drawingBufferHeight, - this.renderer.getContext().RGBA, - this.renderer.getContext().UNSIGNED_BYTE, - this.imageBuffer, - ) - - // The framebuffer coordinate space has (0, 0) in the bottom left, whereas images usually - // have (0, 0) at the top left. Vertical flipping follows: - for (let row = 0; row < this.config.height; row += 1) { - for (let column = 0; column < this.config.width; column++) { - const index = ((this.config.height - row - 1) * this.config.width + column) * 4 - const targetIndex = (row * this.config.width + column) * 3 - - this.observationImageBuffer[targetIndex] = this.imageBuffer[index] - this.observationImageBuffer[targetIndex + 1] = this.imageBuffer[index + 1] - this.observationImageBuffer[targetIndex + 2] = this.imageBuffer[index + 2] - } - } - } - - prepareFeatureBuffer() { - for (const message of this.capturedCollector) { - this.targetFlag = nextFlag(this.runtime, this.rocket) - } - - const dx = - this.rocket.components.rigidBody.translation().x - - this.targetFlag.components.level.flag.x - const dy = - this.rocket.components.rigidBody.translation().y - - this.targetFlag.components.level.flag.y - - const inCapture = this.targetFlag.components.level.inCapture - - this.observationFeatureBuffer.writeFloatLE(this.rocket.components.rigidBody.linvel().x, 0) - this.observationFeatureBuffer.writeFloatLE(this.rocket.components.rigidBody.linvel().y, 4) - this.observationFeatureBuffer.writeFloatLE(this.rotation, 8) - this.observationFeatureBuffer.writeFloatLE(dx, 12) - this.observationFeatureBuffer.writeFloatLE(dy, 16) - this.observationFeatureBuffer.writeFloatLE(inCapture ? 1 : 0, 20) - } -} - -function nextFlag(runtime: Runtime, rocket: EntityWith) { - const distanceToFlag = (flagEntity: EntityWith) => { - const dx = rocket.components.rigidBody.translation().x - flagEntity.components.level.flag.x - const dy = rocket.components.rigidBody.translation().y - flagEntity.components.level.flag.y - return Math.sqrt(dx * dx + dy * dy) - } - - const nextLevel = runtime.factoryContext.store - .find("level") - .filter(level => !level.components.level.captured) - .map(level => [level, distanceToFlag(level)] as const) - .reduce(([minLevel, minDistance], [level, distance]) => - distance < minDistance ? [level, distance] : [minLevel, minDistance], - )[0] - - return nextLevel -} - -global.navigator = { userAgent: "node" } as any - -/* const worldStr2 = "ClwKBkdsb2JhbBJSEigNzcxUwBXJdsBBJQAA7MEtAADKQTUAAO5BPQAAmMBFAAAAQE0AAABAGiYKJAAANEEAAEA/AAD/AODPAACAgP8AAABAxMDA/wDgTwC0////AAo1CgJGMRIvEi0NMzMbQBWLbFdAHdsPyUAlAADswS0AALhANQAA7kE9AACYwEUAAABATQAAAEAKEgoCRzESDAoKDWZmDsEVZmbEQQoSCgJHMhIMCgoNZmYKwRVmZsJBChIKAkczEgwKCg1mZma/FWZmwkEKEgoCRzQSDAoKDWZmRkAVZmbEQQo1CgJGMhIvEi0NzcwywRWLbFdAHdsPyUAlAACawS0AAMpBNQAAIEE9AACYwEUAAABATQAAAEASHAoITm9ybWFsIDESEAoCRzEKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDISEAoCRzIKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDMSEAoCRzMKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDQSEAoCRzQKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDUSEAoCRjIKAkcxCgZHbG9iYWwSHAoITm9ybWFsIDYSEAoCRjIKAkcyCgZHbG9iYWwSHAoITm9ybWFsIDcSEAoCRjIKAkczCgZHbG9iYWwSHAoITm9ybWFsIDgSEAoCRzQKAkYyCgZHbG9iYWw=" const worldStr = @@ -208,34 +10,27 @@ const worldStr = const worldStr3 = "CscCCgZOb3JtYWwSvAIKCg2F65XBFTXTGkISKA2kcLrBFZfjFkIlAAAAwi1SuIlCNa5H+UE9H4X/QUUAAABATQAAAEASKA1SuMFBFZmRGkIlhetRQS3NzFJCNSlcp0I9zcxEQUUAAABATQAAAEASKA0AgEVCFfIboEElAAAoQi0K189BNaRw4UI9rkdZwUUAAABATQAAAEASKA171MBCFcubHcElmpm5Qi0K189BNY/CI0M9rkdZwUUAAABATQAAAEASLQ1syOFCFToytkEdVGuzOiWamblCLSlcZUI1XI8jQz3NzIhBRQAAAEBNAAAAQBItDR/lAUMVk9VNQh2fUDa1JaRw9UItexRsQjWF60FDPQAAlEFFAAAAQE0AAABAEigNw1UzQxVpqkFCJdejJEMtBW94QjXXo0JDPQVvAEJFAAAAQE0AAABACu4KCg1Ob3JtYWwgU2hhcGVzEtwKGt8GCtwGP4UAws3MNEGgEEAAZjYAAP///wB1PAAU////AF5PABT///8AyUtPxP///wAzSg3L////AMBJAcj///8AE0Umzf///wCMVAo5////AJNRpDr///8AVE0WVP///wD0vlZLAAD/AEPI7Bn///8AhcPlOAAA/wAFQZrF////ADS9F8f///8AJMIuwf///wC5xvvF////AOrJ1rf///8Ac8ikQP///wBAxfRF////AGkxi0n///8Aj0LxQgAA/wB1xWY9////AJ/HZAlQUP4AzcUBvQAA/wDwQFzE////ADDGR73///8As8eZPoiI8QBxxWQ3rKz/AFw3LMQAAP8AwkNRtP///wC2RKO4////AEhBe8EAAP8AS0WPPP///wAdSaSx////AMw/Ucj///8A7MBNxv///wDmxnG9////AELCFLr///8Aw8UOof///wAKxCg4AAD/ALg8OMDZ2fsA4j9NwP///wCkxB+/AADwAHGwrr54ePgAVERcwv///wAPwXbA////APW0H0EAAPgASLtnv////wALM67DJSX/AFJApL////8AZj4uwP///wBcu+HATU3/AIU7+8H///8AXMK8Lf///wB7wjM/AAD4AHDCx8D///8AFEH7wP///wAAvnvE////AOTGChL///8A6bncRP///wCAQddAAAD4AB/AxLH///8AIL9RPQAA+ACZwqvG////AOLCLkQAAPgAIcTrwP///wDtwQPH////AOLJbqz///8ALsR6QwAA+AD+x8zA////APtF90kyMv8AH7mZQCcn/wCNxHo8tbX/AIDAiETKyv8AXEAgSgAA+AClyAqS////AH9EG0n///8AS0ypRP///wAxSIK7MDToANjBdUf///8A58yjxP///wCByD1EMDToAIzCYMv///8AnMq3MzA06AC+QenF////ANzGT0T///8AtMFSR////wBzRb85lpj/AFJALEQwNOgAqMIpPjA06AAgyiCF////AAPEE77///8AzT4FSnN1/wAzxWFCMDToAA23PcKXl/8AGcLmQDA06ADMPUnJu77/AFrGxsL///8A1TRGSjA06ACKwik8MDToAE3Apcn///8Ar8SawP///wBsygqP////ABHI8z0wNOgAAABTzv///wAa9wMK9APNzJNCj8JlQP///wBmtly8////ABa2jsg2Nv8AO0SENwAA+ACkvrtEvLz/AG0uOEX///8A4UaHPv///wA+QlXFAAD4AApB2L4AAPgAeDLVRP///wATSHHAAAD4ADhA3EP///8As0MKvAAA8ADOPxM4AAD4AEjBTUD///8Arj5TP3B0+ACyKw9DaGz4ALm6eDz///8AKT4MSP///wDhPy5CAAD/APS/XEL///8A+EV6PwAA/wAdsXtBp6f/AGzEpEEAAP8AisfEuf///wDXwVJI////AJpEaUf///8AhUfxQP///wB7RA3FAAD/ANdBTzUAAP8AC8C9Rv///wBGQoVE////APRMpDz///8A7kS3yAAA/wDLR9HB////AFLHNscAAP8AR0HNwf///wDsvtLGAAD/AABE5kD///8AD0JIRv///wD0RNJA////AEVFqcD///8A3ESpwwAA/wAuwgtJ////AARBqEj///8ALUdbSf///wA01Hks////AHjCAL3///8AF8s5x////wC4vlPP////AME1O8f///8AhsIAPgAA+ABcxZXC7e3/AIrEpUMAAPgAjcbDxcvL/wBdQFzF////AEjI+8EAAOAAQ0GZvf///wAGN77AFRX/APlFXDz///8AikEzwkhI+ADcQmoy////AArNAgoHUmV2ZXJzZRLBAgoPDRydLkMVk5lFQh2z7Zk2EigNpHC6wRWX4xZCJQAAAMItAABMQjUAAEDBPR+F/0FFAAAAQE0AAABAEigNUrjBQRWZkRpCJR+FAMItZuaJQjUAAPpBPQAAAEJFAAAAQE0AAABAEigNAIBFQhXyG6BBJQAAUEEthetRQjWkcKdCPVK4TkFFAAAAQE0AAABAEigNe9TAQhXLmx3BJTQzKEItCtfPQTUeBeJCPa5HWcFFAAAAQE0AAABAEi0NbMjhQhU6MrZBHVRrszolmpm5Qi1SuNRBNVyPI0M9ZmZawUUAAABATQAAAEASLQ0f5QFDFZPVTUIdn1A2tSWk8LlCLXsUZUI1hSskQz0AAIZBRQAAAEBNAAAAQBIoDcNVM0MVaapBQiUAgPVCLQAAbEI1AABCQz0AAJRBRQAAAEBNAAAAQBIhCgZOb3JtYWwSFwoNTm9ybWFsIFNoYXBlcwoGTm9ybWFsEiMKB1JldmVyc2USGAoNTm9ybWFsIFNoYXBlcwoHUmV2ZXJzZQ==" -const world = WorldModel.decode(Buffer.from(worldStr, "base64")) +const world = WorldModel.decode(Buffer.from(worldStr2, "base64")) const env = new GameEnvironment( world, - "Normal", + "Normal 1", { - width: 64, - height: 64, - stepsPerFrame: 4, + grayScale: true, + size: 64, + pixelsPerUnit: 2, + stepsPerFrame: 6, }, game => new DefaultGameReward(game), ) -const png = new PNG({ - width: 64, - height: 64, -}) - for (let i = 0; i < 30; ++i) { - const [r, , image] = env.step(Buffer.from([5])) + const [r, ,] = env.step(Buffer.from([0])) console.log(r) - png.data.set(image) - fs.writeFileSync( - `imgs/output${i}.png`, - PNG.sync.write(png, { colorType: 2, inputHasAlpha: false }), - ) + writeFileSync(`imgs/output${i}.png`, env.generatePng()) } +/* fs.writeFileSync("output.png", PNG.sync.write(png, { colorType: 2, inputHasAlpha: false })) process.exit(0) diff --git a/packages/learning-gym/src/module-scene/module-scene.ts b/packages/learning-gym/src/module-scene/module-scene.ts deleted file mode 100644 index 27d3bf8b..00000000 --- a/packages/learning-gym/src/module-scene/module-scene.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { SystemStack } from "runtime-framework" -import { RuntimeComponents } from "runtime/src/core/runtime-components" -import { RuntimeFactoryContext } from "runtime/src/core/runtime-factory-context" -import { RuntimeSystemContext } from "runtime/src/core/runtime-system-stack" -import { Runtime } from "runtime/src/runtime" -import * as THREE from "three" -import { MutatableShapeGeometry } from "./mutatable-shape-geometry" -import { Flag } from "./objects/flag" -import { Rocket } from "./objects/rocket" - -export class ModuleScene { - private flags: Flag[] = [] - private rocket: Rocket - - constructor( - private scene: THREE.Scene, - private runtime: Runtime, - ) { - const [rocketEntity] = runtime.factoryContext.store.find("rocket", "rigidBody") - this.rocket = new Rocket(rocketEntity) - this.scene.add(this.rocket) - - this.initShapes(runtime) - - for (const levelEntity of runtime.factoryContext.store.find("level")) { - if (levelEntity.components.level.hideFlag) { - continue - } - - const flag = new Flag(levelEntity) - scene.add(flag) - this.flags.push(flag) - } - } - - private initShapes( - runtime: SystemStack, RuntimeSystemContext>, - ) { - const shapes = runtime.factoryContext.store.newSet("shape") - - for (const shape of shapes) { - const shapeGeometry = new MutatableShapeGeometry( - shape.components.shape.vertices.map(vertex => ({ - position: new THREE.Vector2(vertex.position.x, vertex.position.y), - color: 0xff0000, - })), - ) - const shapeMaterial = new THREE.MeshBasicMaterial({ vertexColors: true }) - const shapeMesh = new THREE.Mesh(shapeGeometry, shapeMaterial) - - this.scene.add(shapeMesh) - } - } - - onUpdate() { - this.rocket.onUpdate() - } - - getScene() { - return this.scene - } -} diff --git a/packages/learning-gym/src/module-scene/mutatable-shape-geometry.ts b/packages/learning-gym/src/module-scene/mutatable-shape-geometry.ts deleted file mode 100644 index 1ca03ede..00000000 --- a/packages/learning-gym/src/module-scene/mutatable-shape-geometry.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Point } from "runtime/src/model/point" -import { ShapeVertex } from "runtime/src/model/world/shape-model" -import { BufferGeometry, Color, Float32BufferAttribute, ShapeUtils } from "three" - -export class MutatableShapeGeometry extends BufferGeometry { - constructor(shapeVertices?: ShapeVertex[]) { - super() - - if (shapeVertices) { - this.update(shapeVertices) - } - } - - update(shapeVertices: ShapeVertex[]) { - const vertices = shapeVertices.map(vertex => vertex.position) - - // check direction of vertices - const iterate = ShapeUtils.isClockWise(vertices) ? iterateInOrder : iterateInReverseOrder - - const faces = ShapeUtils.triangulateShape(vertices, []) - - const indices = [] - for (let i = 0, l = faces.length; i < l; i++) { - const face = faces[i] - - const a = face[0] - const b = face[1] - const c = face[2] - - indices.push(a, b, c) - } - - const buffer = new Float32Array(vertices.length * 3) - const bufferColors = new Float32Array(vertices.length * 3) - - const color = new Color() - iterate(vertices, (i, vertex) => { - buffer[i * 3 + 0] = vertex.x - buffer[i * 3 + 1] = vertex.y - buffer[i * 3 + 2] = 0 - - color.r = ((shapeVertices[i].color >> 16) & 0xff) / 255 - color.g = ((shapeVertices[i].color >> 8) & 0xff) / 255 - color.b = ((shapeVertices[i].color >> 0) & 0xff) / 255 - - color.convertSRGBToLinear() - - bufferColors[i * 3 + 0] = color.r - bufferColors[i * 3 + 1] = color.g - bufferColors[i * 3 + 2] = color.b - }) - - this.setIndex(indices) - - this.setAttribute("position", new Float32BufferAttribute(buffer, 3)) - this.setAttribute("color", new Float32BufferAttribute(bufferColors, 3)) - - this.attributes.color.needsUpdate = true - this.attributes.position.needsUpdate = true - } -} - -function iterateInOrder(vertices: Point[], callback: (i: number, vertex: Point) => void) { - for (let i = 0; i < vertices.length; i++) { - callback(i, vertices[i]) - } -} - -function iterateInReverseOrder(vertices: Point[], callback: (i: number, vertex: Point) => void) { - for (let i = vertices.length - 1; i >= 0; i--) { - callback(i, vertices[i]) - } -} diff --git a/packages/learning-gym/src/module-scene/objects/flag.ts b/packages/learning-gym/src/module-scene/objects/flag.ts deleted file mode 100644 index f81129bd..00000000 --- a/packages/learning-gym/src/module-scene/objects/flag.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EntityWith } from "runtime-framework" -import { RuntimeComponents } from "runtime/src/core/runtime-components" -import * as THREE from "three" - -/* -const prototypeRed = new Svg( - '', -) - -const prototypeGreen = new Svg( - '', -) -*/ - -export class Flag extends THREE.Object3D { - constructor(levelEntity: EntityWith) { - super() - - const geometry = new THREE.BoxGeometry(1, 1, 1) - const materialRed = new THREE.MeshBasicMaterial({ color: 0x0000ff }) - - const mesh = new THREE.Mesh(geometry, materialRed) - - this.add(mesh) - - this.position.set( - levelEntity.components.level.capturePosition.x, - levelEntity.components.level.capturePosition.y + - levelEntity.components.level.captureSize.y, - 0, - ) - - this.scale.set( - levelEntity.components.level.captureSize.x * 2, - levelEntity.components.level.captureSize.y * 2, - 1.0, - ) - } -} diff --git a/packages/learning-gym/src/module-scene/objects/rocket.ts b/packages/learning-gym/src/module-scene/objects/rocket.ts deleted file mode 100644 index 5f7dda11..00000000 --- a/packages/learning-gym/src/module-scene/objects/rocket.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { EntityWith } from "runtime-framework" -import { EntityType } from "runtime/proto/world" -import { RuntimeComponents } from "runtime/src/core/runtime-components" -import { entityRegistry } from "runtime/src/model/world/entity-registry" -import * as THREE from "three" - -export class Rocket extends THREE.Object3D { - constructor(private entity: EntityWith) { - super() - - const rocketEntry = entityRegistry[EntityType.ROCKET] - - const geometry = new THREE.BoxGeometry(rocketEntry.width, rocketEntry.height, 1) - const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) - const cube = new THREE.Mesh(geometry, material) - - this.add(cube) - - this.position.set( - entity.components.rigidBody.translation().x, - entity.components.rigidBody.translation().y, - 0, - ) - } - - onUpdate() { - this.position.set( - this.entity.components.rigidBody.translation().x, - this.entity.components.rigidBody.translation().y, - 0, - ) - } -} diff --git a/packages/learning/package.json b/packages/learning/package.json index 63a35be8..0ef09f49 100644 --- a/packages/learning/package.json +++ b/packages/learning/package.json @@ -23,7 +23,7 @@ "@tensorflow/tfjs": "^4.19.0", "@tensorflow/tfjs-backend-webgl": "^4.19.0", "@tensorflow/tfjs-backend-webgpu": "^4.19.0", - "@tensorflow/tfjs-node": "^4.19.0", + "@tensorflow/tfjs-node-gpu": "^4.19.0", "@types/prompts": "^2.4.9", "@types/sat": "^0.0.35", "@types/three": "^0.164.0", diff --git a/packages/learning/src/main.ts b/packages/learning/src/main.ts index 07adab2e..2c5c214b 100644 --- a/packages/learning/src/main.ts +++ b/packages/learning/src/main.ts @@ -1,20 +1,30 @@ -import * as tf from "@tensorflow/tfjs-node" -import { GameEnvironment } from "learning-gym/src/main" +import * as tf from "@tensorflow/tfjs-node-gpu" +import { Buffer } from "buffer" +import { GameEnvironment } from "learning-gym/src/game-environment" import { WorldModel } from "runtime/proto/world" import { DefaultGameReward } from "web-game/src/game/reward/default-reward" import { Environment, PPO } from "./ppo/ppo" class SplitLayer extends tf.layers.Layer { - computeOutputShape(inputShape: tf.Shape[]): tf.Shape[] { - return [inputShape[0], inputShape[0]] + computeOutputShape(inputShape: tf.Shape | tf.Shape[]): tf.Shape | tf.Shape[] { + if (Array.isArray(inputShape[0])) { + inputShape = inputShape[0] + } + + return [ + [inputShape[0], 6], + [inputShape[0], inputShape[1] - 6], + ] } - constructor(private left: number) { + constructor() { super() } - call(inputs: tf.Tensor): tf.Tensor[] { - console.error("inputs shape: ", inputs.shape) + call(inputs: tf.Tensor | tf.Tensor[]): tf.Tensor[] { + if (Array.isArray(inputs)) { + inputs = inputs[0] + } const len = inputs.shape[1] @@ -22,9 +32,10 @@ class SplitLayer extends tf.layers.Layer { throw new Error("Input is too short") } - console.log("inputs shape: ", inputs.shape) + const left = inputs.slice([0, 0], [-1, 6]) + const right = inputs.slice([0, 6], [-1, len - 6]) - return tf.split(inputs, [this.left, len - this.left], 1) + return [left, right] } static get className() { @@ -32,60 +43,93 @@ class SplitLayer extends tf.layers.Layer { } } +tf.serialization.registerClass(SplitLayer) + const worldStr2 = "ClwKBkdsb2JhbBJSEigNzcxUwBXJdsBBJQAA7MEtAADKQTUAAO5BPQAAmMBFAAAAQE0AAABAGiYKJAAANEEAAEA/AAD/AODPAACAgP8AAABAxMDA/wDgTwC0////AAo1CgJGMRIvEi0NMzMbQBWLbFdAHdsPyUAlAADswS0AALhANQAA7kE9AACYwEUAAABATQAAAEAKEgoCRzESDAoKDWZmDsEVZmbEQQoSCgJHMhIMCgoNZmYKwRVmZsJBChIKAkczEgwKCg1mZma/FWZmwkEKEgoCRzQSDAoKDWZmRkAVZmbEQQo1CgJGMhIvEi0NzcwywRWLbFdAHdsPyUAlAACawS0AAMpBNQAAIEE9AACYwEUAAABATQAAAEASHAoITm9ybWFsIDESEAoCRzEKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDISEAoCRzIKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDMSEAoCRzMKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDQSEAoCRzQKAkYxCgZHbG9iYWwSHAoITm9ybWFsIDUSEAoCRjIKAkcxCgZHbG9iYWwSHAoITm9ybWFsIDYSEAoCRjIKAkcyCgZHbG9iYWwSHAoITm9ybWFsIDcSEAoCRjIKAkczCgZHbG9iYWwSHAoITm9ybWFsIDgSEAoCRzQKAkYyCgZHbG9iYWw=" const world = WorldModel.decode(Buffer.from(worldStr2, "base64")) -const env = new GameEnvironment( - world, - "Normal 1", - { - stepsPerFrame: 4, - width: 64, - height: 64, - }, - g => new DefaultGameReward(g), -) +function newEnvWrapped() { + const inputBuffer = Buffer.alloc(1) -const inputBuffer = Buffer.alloc(1) + function bufferToFloats(buffer: Buffer): number[] { + const floats = [] -const envWrapped: Environment = { - reset: () => { - const [image, addedFeatures] = env.reset() + for (let i = 0; i < buffer.length; i += 4) { + floats.push(buffer.readFloatLE(i)) + } - const imageArray = Array.from(image) - const addedFeaturesArray = Array.from(addedFeatures) + return floats + } - return addedFeaturesArray.concat(imageArray) - }, - step: (action: number | number[]) => { - if (Array.isArray(action)) { - action = action[0] - } + const modes = [...Array(8).keys()].map(i => `Normal ${i + 1}`) - inputBuffer.writeUInt8(action, 0) - const [reward, done, image, addedFeatures] = env.step(inputBuffer) + const rawEnv = new GameEnvironment( + world, + modes, + { + grayScale: true, + stepsPerFrame: 6, + size: 64, + pixelsPerUnit: 2, + }, + g => new DefaultGameReward(g), + ) + const envWrapped: Environment = { + reset: () => { + const [image, addedFeatures] = rawEnv.reset() - const imageArray = Array.from(image) - const addedFeaturesArray = Array.from(addedFeatures) + const imageArray = Array.from(image) + const addedFeaturesArray = bufferToFloats(addedFeatures) - return [addedFeaturesArray.concat(imageArray), reward, done] - }, + return addedFeaturesArray.concat(imageArray) + }, + step: (action: number | number[]) => { + if (Array.isArray(action)) { + action = action[0] + } + + inputBuffer.writeUInt8(action, 0) + const [reward, done, image, addedFeatures] = rawEnv.step(inputBuffer) + + const imageArray = Array.from(image) + const addedFeaturesArray = bufferToFloats(addedFeatures) + + return [addedFeaturesArray.concat(imageArray), reward, done] + }, + } + + return envWrapped } +const env = newEnvWrapped() +const testEnv = newEnvWrapped() + +/* +for (let i = 0; i < 60; ++i) { + const [, r, done] = env.step(5) + console.log(r) + + if (done) { + env.reset() + } +} +*/ + function model() { const featureCount = 6 + const size = 64 - const width = 64 - const height = 64 + const input = tf.input({ shape: [size * size + featureCount] }) - const input = tf.input({ shape: [width * height * 3 + featureCount] }) + const [addedFeatures, imageFlat] = new SplitLayer(featureCount).apply( + input, + ) as tf.SymbolicTensor[] - const splitLayer = new SplitLayer(featureCount) - const x = splitLayer.apply(input) - console.log("x: ", x) - let [addedFeatures, image] = x as tf.SymbolicTensor[] + let image = tf.layers + .reshape({ targetShape: [size, size, 1] }) + .apply(imageFlat) as tf.SymbolicTensor image = tf.layers .conv2d({ @@ -114,9 +158,11 @@ function model() { }) .apply(image) as tf.SymbolicTensor - const imageFlat = tf.layers.flatten().apply(image) + const imageProcessedFlat = tf.layers.flatten().apply(image) - const imageReduced = tf.layers.dense({ units: 256 }).apply(imageFlat) as tf.SymbolicTensor + const imageReduced = tf.layers + .dense({ units: 256 }) + .apply(imageProcessedFlat) as tf.SymbolicTensor let features = tf.layers.concatenate().apply([imageReduced, addedFeatures]) @@ -133,25 +179,89 @@ function model() { const ppo = new PPO( { - steps: 512, - epochs: 20, + steps: 2048, + epochs: 5, policyLearningRate: 1e-4, valueLearningRate: 1e-4, clipRatio: 0.2, targetKL: 0.01, gamma: 0.99, lambda: 0.95, - observationDimension: 64 * 64 * 3 + 6, actionSpace: { class: "Discrete", len: 6, }, }, - envWrapped, + env, model(), model(), ) -ppo.learn(100) +function testReward() { + let reward = 0 + let observation = testEnv.reset() -while (true) {} + for (;;) { + const actionRaw = ppo.act(observation) + const action = Array.isArray(actionRaw) ? actionRaw[0] : actionRaw + + const [newObservation, r, done] = testEnv.step(0) + + observation = newObservation + reward += r + + if (done) { + break + } + } + + return reward +} + +function averageReward() { + let sum = 0 + + for (let i = 0; i < 16; ++i) { + sum += testReward() + } + + return sum / 16 +} + +ppo.restore() + .then(() => { + console.log("Model restored") + }) + .catch(e => { + console.log("Model not restored: ", e) + }) + .finally(async () => { + let bestReward = averageReward() + + for (let i = 0; ; ++i) { + ppo.learn(2048 * (i + 1)) + const potential = testReward() + + if (potential > bestReward) { + const reward = averageReward() + + if (reward > bestReward) { + bestReward = reward + console.log(`New best reward: ${bestReward}`) + + await ppo + .save() + .catch(e => console.log("Model not saved: ", e)) + .then(() => { + console.log("Model saved") + }) + } else { + console.log( + `Iteration ${i + 1}, Reward: ${reward}, Best: ${bestReward} (was considered)`, + ) + } + } else { + console.log(`Iteration ${i + 1}, Potential: ${potential}, Best: ${bestReward}`) + } + } + }) diff --git a/packages/learning/src/ppo/ppo.ts b/packages/learning/src/ppo/ppo.ts index 525594cc..2c1583e0 100644 --- a/packages/learning/src/ppo/ppo.ts +++ b/packages/learning/src/ppo/ppo.ts @@ -1,4 +1,4 @@ -import * as tf from "@tensorflow/tfjs" +import * as tf from "@tensorflow/tfjs-node-gpu" class ReplayBuffer { private gamma: number @@ -134,7 +134,6 @@ interface PPOConfig { clipRatio: number targetKL: number - observationDimension: number actionSpace: Space } @@ -198,13 +197,13 @@ export class PPO { } async save() { - await this.actor.save("localstorage://actor") - await this.critic.save("localstorage://critic") + await this.actor.save("file://./training/actor") + await this.critic.save("file://./training/critic") } async restore() { - this.actor = await tf.loadLayersModel("localstorage://actor") - this.critic = await tf.loadLayersModel("localstorage://critic") + this.actor = await tf.loadLayersModel("file://./training/actor/model.json") + this.critic = await tf.loadLayersModel("file://./training/critic/model.json") } act(observation: number[]): number | number[] { diff --git a/packages/runtime/src/core/level/level-factory.ts b/packages/runtime/src/core/level/level-factory.ts index f7a09c2a..68ba904a 100644 --- a/packages/runtime/src/core/level/level-factory.ts +++ b/packages/runtime/src/core/level/level-factory.ts @@ -25,8 +25,6 @@ export const newLevel = ( new factoryContext.rapier.RigidBodyDesc(factoryContext.rapier.RigidBodyType.Fixed), ) - console.log(body.handle) - const colliderDesc = factoryContext.rapier.ColliderDesc.polyline( new Float32Array([ levelEntity.camera.topLeft.x, diff --git a/packages/runtime/src/runtime.ts b/packages/runtime/src/runtime.ts index 826322be..bdb43b36 100644 --- a/packages/runtime/src/runtime.ts +++ b/packages/runtime/src/runtime.ts @@ -19,8 +19,6 @@ export const newRuntime = (rapier: typeof RAPIER, world: WorldModel, gamemodeNam const groups = gamemode.groups.map(group => world.groups[group]) - console.log("VECTOR2: ", rapier) - const context: RuntimeFactoryContext = { store: createEntityStore(), messageStore: createMessageStore(), diff --git a/packages/web-game/src/game/game-agent-wrapper.ts b/packages/web-game/src/game/game-agent-wrapper.ts index 8c29021f..6ed40554 100644 --- a/packages/web-game/src/game/game-agent-wrapper.ts +++ b/packages/web-game/src/game/game-agent-wrapper.ts @@ -14,11 +14,21 @@ export class GameAgentWrapper { constructor( private runtime: Runtime, scene: THREE.Scene, + grayScale: boolean, + cameraSize: number, ) { scene.background = new THREE.Color(0) - this.sceneModule = new ModuleSceneAgent(scene, runtime) + this.sceneModule = new ModuleSceneAgent(scene, runtime, grayScale) + + this.camera = new THREE.OrthographicCamera( + -cameraSize, + cameraSize, + cameraSize, + -cameraSize, + -1000, + 1000, + ) - this.camera = new THREE.OrthographicCamera(-16, 16, 16, -16, -1000, 1000) this.rocket = runtime.factoryContext.store.find("rocket", "rigidBody")[0] this.camera.position.set( diff --git a/packages/web-game/src/game/modules/module-scene-agent/colors.ts b/packages/web-game/src/game/modules/module-scene-agent/colors.ts new file mode 100644 index 00000000..0546e126 --- /dev/null +++ b/packages/web-game/src/game/modules/module-scene-agent/colors.ts @@ -0,0 +1,20 @@ +export interface AgentColors { + rocket: number + flagCaptureRegion: number + shape: number + outOfBounds: number +} + +export const agentColorsRGB: AgentColors = { + rocket: 0x00ff00, + flagCaptureRegion: 0x0000ff, + shape: 0xff0000, + outOfBounds: 0xff0000, +} + +export const agentColorsGrayScale: AgentColors = { + rocket: 0xdddddd, + flagCaptureRegion: 0x333333, + shape: 0xffffff, + outOfBounds: 0xffffff, +} diff --git a/packages/web-game/src/game/modules/module-scene-agent/module-scene-agent.ts b/packages/web-game/src/game/modules/module-scene-agent/module-scene-agent.ts index 383a77b2..bf7a6bd9 100644 --- a/packages/web-game/src/game/modules/module-scene-agent/module-scene-agent.ts +++ b/packages/web-game/src/game/modules/module-scene-agent/module-scene-agent.ts @@ -4,6 +4,7 @@ import { RuntimeFactoryContext } from "runtime/src/core/runtime-factory-context" import { RuntimeSystemContext } from "runtime/src/core/runtime-system-stack" import { Runtime } from "runtime/src/runtime" import * as THREE from "three" +import { AgentColors, agentColorsGrayScale, agentColorsRGB } from "./colors" import { MutatableShapeGeometry } from "./mutatable-shape-geometry" import { Flag } from "./objects/flag" import { Rocket } from "./objects/rocket" @@ -22,26 +23,29 @@ export class ModuleSceneAgent { constructor( private scene: THREE.Scene, private runtime: Runtime, + private grayScale: boolean, ) { + const colors = grayScale ? agentColorsGrayScale : agentColorsRGB + const [rocketEntity] = runtime.factoryContext.store.find("rocket", "rigidBody") - this.rocket = new Rocket(rocketEntity) + this.rocket = new Rocket(rocketEntity, colors) this.scene.add(this.rocket) - this.initShapes(runtime) + this.initShapes(runtime, colors) for (const levelEntity of runtime.factoryContext.store.find("level")) { if (levelEntity.components.level.hideFlag) { continue } - const flag = new Flag(levelEntity) + const flag = new Flag(levelEntity, colors) scene.add(flag) this.flags.push(flag) } this.upBound = new THREE.Mesh( new THREE.PlaneGeometry(100, 100), - new THREE.MeshBasicMaterial({ color: 0xff0000 }), + new THREE.MeshBasicMaterial({ color: colors.outOfBounds }), ) this.downBound = this.upBound.clone() @@ -58,6 +62,7 @@ export class ModuleSceneAgent { private initShapes( runtime: SystemStack, RuntimeSystemContext>, + colors: AgentColors, ) { const shapes = runtime.factoryContext.store.newSet("shape") @@ -65,7 +70,7 @@ export class ModuleSceneAgent { const shapeGeometry = new MutatableShapeGeometry( shape.components.shape.vertices.map(vertex => ({ position: new THREE.Vector2(vertex.position.x, vertex.position.y), - color: 0xff0000, + color: colors.shape, })), ) const shapeMaterial = new THREE.MeshBasicMaterial({ vertexColors: true }) @@ -81,8 +86,6 @@ export class ModuleSceneAgent { const currentLevel = this.rocket.entity.components.rocket!.currentLevel if (currentLevel !== this.previousCurrentLevel) { - console.log("Current level changed: ", currentLevel) - const tl = currentLevel.components.level.boundsTL const br = currentLevel.components.level.boundsBR diff --git a/packages/web-game/src/game/modules/module-scene-agent/objects/flag.ts b/packages/web-game/src/game/modules/module-scene-agent/objects/flag.ts index f81129bd..b1b706e9 100644 --- a/packages/web-game/src/game/modules/module-scene-agent/objects/flag.ts +++ b/packages/web-game/src/game/modules/module-scene-agent/objects/flag.ts @@ -1,6 +1,7 @@ import { EntityWith } from "runtime-framework" import { RuntimeComponents } from "runtime/src/core/runtime-components" import * as THREE from "three" +import { AgentColors } from "../colors" /* const prototypeRed = new Svg( @@ -13,11 +14,11 @@ const prototypeGreen = new Svg( */ export class Flag extends THREE.Object3D { - constructor(levelEntity: EntityWith) { + constructor(levelEntity: EntityWith, colors: AgentColors) { super() const geometry = new THREE.BoxGeometry(1, 1, 1) - const materialRed = new THREE.MeshBasicMaterial({ color: 0x0000ff }) + const materialRed = new THREE.MeshBasicMaterial({ color: colors.flagCaptureRegion }) const mesh = new THREE.Mesh(geometry, materialRed) diff --git a/packages/web-game/src/game/modules/module-scene-agent/objects/rocket.ts b/packages/web-game/src/game/modules/module-scene-agent/objects/rocket.ts index daecc347..76f4245f 100644 --- a/packages/web-game/src/game/modules/module-scene-agent/objects/rocket.ts +++ b/packages/web-game/src/game/modules/module-scene-agent/objects/rocket.ts @@ -3,15 +3,19 @@ import { EntityType } from "runtime/proto/world" import { RuntimeComponents } from "runtime/src/core/runtime-components" import { entityRegistry } from "runtime/src/model/world/entity-registry" import * as THREE from "three" +import { AgentColors } from "../colors" export class Rocket extends THREE.Object3D { - constructor(public entity: EntityWith) { + constructor( + public entity: EntityWith, + colors: AgentColors, + ) { super() const rocketEntry = entityRegistry[EntityType.ROCKET] const geometry = new THREE.BoxGeometry(rocketEntry.width, rocketEntry.height, 1) - const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 }) + const material = new THREE.MeshBasicMaterial({ color: colors.rocket }) const cube = new THREE.Mesh(geometry, material) this.add(cube) diff --git a/packages/web/package.json b/packages/web/package.json index b12f6be5..c5b165ba 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -50,6 +50,7 @@ "zustand": "^4.4.1" }, "devDependencies": { + "@tailwindcss/container-queries": "^0.1.1", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@types/three": "^0.155.1", diff --git a/packages/web/src/app/campaign/World.tsx b/packages/web/src/app/campaign/World.tsx index 1a7992f1..c834973e 100644 --- a/packages/web/src/app/campaign/World.tsx +++ b/packages/web/src/app/campaign/World.tsx @@ -33,7 +33,7 @@ export function World(props: { world?: WorldView; locked?: boolean; onSelected: return ( - + {props.world === undefined &&
} +
{props.children}
@@ -68,7 +68,7 @@ function WorldContainerInner(props: { children: React.ReactNode }) { function WorldContainerOuter(props: { children: React.ReactNode }) { return ( -
+
{props.children}
) @@ -136,9 +136,7 @@ function LockedOverlay() {
-
- -
+
@@ -147,45 +145,46 @@ function LockedOverlay() { isMobile ? "flex" : "hidden group-hover:flex" }`} > -
Beat the previous map!
) } -export function Overlay(props: { world?: WorldView }) { - function TitleInLocked() { +export function Overlay(props: { world?: WorldView; locked?: boolean }) { + function Title(props: { children: React.ReactNode }) { return ( -
- Locked +
+ {props.children}
) } - function TitleInNormal() { + function TitleInLocked() { return ( -
- {props.world?.id.name} -
+ + <LockedSvg width="24" height="24" /> + ) } + function TitleInNormal() { + return {props.world?.id.name} + } + function TitleInUndefined() { return ( -
+ <div className="loading loading-sm" /> - </div> + ) } return ( -
-
- {!todoProgressFeature && } - {!!todoProgressFeature && props.world && } - {!props.world && } -
+
+ {props.locked && } + {!props.locked && props.world && } + {!props.world && }
) } diff --git a/packages/web/src/app/layout/Layout.tsx b/packages/web/src/app/layout/Layout.tsx index b409d663..2947e27f 100644 --- a/packages/web/src/app/layout/Layout.tsx +++ b/packages/web/src/app/layout/Layout.tsx @@ -1,5 +1,6 @@ import { Outlet, useLocation } from "react-router-dom" import { AuthButton } from "../../common/components/auth-button/AuthButton" +import { BackArrowSvg } from "../../common/components/inline-svg/BackArrow" import { BoxArrowInRight } from "../../common/components/inline-svg/BoxArrowInRight" import { useAppStore } from "../../common/storage/app-store" import { Alert } from "./Alert" @@ -58,7 +59,7 @@ function Logo() { export function LayoutWithMenu() { function ResponsiveLoginButton() { return ( - +
LOGIN
@@ -72,7 +73,10 @@ export function LayoutWithMenu() {
-
+
+
+ +
diff --git a/packages/web/tailwind.better-containers.cjs b/packages/web/tailwind.better-containers.cjs new file mode 100644 index 00000000..b07ea7ff --- /dev/null +++ b/packages/web/tailwind.better-containers.cjs @@ -0,0 +1,98 @@ +import plugin from "tailwindcss/plugin" + +export const betterContainersPlugin = plugin( + function containerQueries({ matchUtilities, matchVariant, theme }) { + let values = theme("containers") ?? {} + + function parseNumberValue(value) { + let numericValue = value.match(/^(\d+\.\d+|\d+|\.\d+)\D+/)?.[1] ?? null + if (numericValue === null) return null + + return parseFloat(value) + } + + matchUtilities( + { + "@container": (value, { modifier }) => { + return { + "container-type": value, + "container-name": modifier, + } + }, + }, + { + values: { + DEFAULT: "size", + normal: "normal", + "inline-size": "inline-size", + }, + modifiers: "any", + }, + ) + matchVariant( + "@", + (value = "", { modifier }) => { + let parsedNumber = null + let parsedObject = null + + if (typeof value === "object") { + parsedObject = JSON.parse(JSON.stringify(value)) || null + } else { + parsedNumber = parseNumberValue(value) + } + + if (parsedObject !== null) { + return `@container ${modifier ?? ""} ${parsedObject.raw}` + } + + return parsedNumber !== null + ? `@container ${modifier ?? ""} (min-width: ${value})` + : [] + }, + { + values, + sort(aVariant, zVariant) { + let a = parseFloat(aVariant.value) + let z = parseFloat(zVariant.value) + + if (a === null || z === null) return 0 + + // Sort values themselves regardless of unit + if (a - z !== 0) return a - z + + let aLabel = aVariant.modifier ?? "" + let zLabel = zVariant.modifier ?? "" + + // Explicitly move empty labels to the end + if (aLabel === "" && zLabel !== "") { + return 1 + } else if (aLabel !== "" && zLabel === "") { + return -1 + } + + // Sort labels alphabetically in the English locale + // We are intentionally overriding the locale because we do not want the sort to + // be affected by the machine's locale (be it a developer or CI environment) + return aLabel.localeCompare(zLabel, "en", { numeric: true }) + }, + }, + ) + }, + { + theme: { + containers: { + xs: "20rem", + sm: "24rem", + md: "28rem", + lg: "32rem", + xl: "36rem", + "2xl": "42rem", + "3xl": "48rem", + "4xl": "56rem", + "5xl": "64rem", + "6xl": "72rem", + "7xl": "80rem", + }, + }, + }, +) diff --git a/packages/web/tailwind.config.cjs b/packages/web/tailwind.config.cjs index 2598a555..48e4136d 100644 --- a/packages/web/tailwind.config.cjs +++ b/packages/web/tailwind.config.cjs @@ -1,3 +1,5 @@ +import { betterContainersPlugin } from "./tailwind.better-containers.cjs" + /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx,mdx}"], @@ -11,9 +13,14 @@ module.exports = { hsm: { raw: "(min-height: 640px)" }, hmd: { raw: "(min-height: 768px)" }, }, + containers: { + hxs: { raw: "(min-height: 14rem)" }, + hsm: { raw: "(min-height: 18rem)" }, + hmd: { raw: "(min-height: 22rem)" }, + }, }, }, - plugins: [require("daisyui"), require("tailwind-scrollbar")], + plugins: [require("daisyui"), require("tailwind-scrollbar"), betterContainersPlugin], daisyui: { themes: [ { diff --git a/yarn.lock b/yarn.lock index 93278179..4b9f3fe5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3617,6 +3617,11 @@ dependencies: "@swc/counter" "^0.1.3" +"@tailwindcss/container-queries@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz#9a759ce2cb8736a4c6a0cb93aeb740573a731974" + integrity sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA== + "@tanstack/query-core@4.36.1": version "4.36.1" resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.36.1.tgz#79f8c1a539d47c83104210be2388813a7af2e524" @@ -3699,10 +3704,10 @@ resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-layers/-/tfjs-layers-4.19.0.tgz#73b5a3f5580807d5d56188d9b6ad4658d810fefa" integrity sha512-NufvuRaZdIyoG+R13d7oL8G5Bywox+ihPMiMZ3tWU+me8C8Y0pVC69mrnhOS9R8an7GDxKKSTTNEZhUvPvMGiQ== -"@tensorflow/tfjs-node@^4.19.0": +"@tensorflow/tfjs-node-gpu@^4.19.0": version "4.19.0" - resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-node/-/tfjs-node-4.19.0.tgz#a922db9cd8284cee8eb7655e539dab12c32c272c" - integrity sha512-1HLIAuu5azP8SW7t5EZc1W5VOdjWndJYz1N1agz0It/tMtnuWIdAfcY08VjfuiI/NhAwuPShehqv6CZ3SYh+Vg== + resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-node-gpu/-/tfjs-node-gpu-4.19.0.tgz#18bd846df48dc561986a742d716938ffef6da939" + integrity sha512-LKpCZ44xZ1OTln5c02xtN0c02w/wy8fQ4tiZvOzIIjHp5yu8+kY55jpD6pMAsii/tIo4Glda+8j/9SDZjl1p8Q== dependencies: "@mapbox/node-pre-gyp" "1.0.9" "@tensorflow/tfjs" "4.19.0"