@mpgd/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.d.ts +2 -0
  3. package/dist/bin.js +3 -0
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.js +930 -0
  6. package/package.json +64 -0
  7. package/templates/phaser-game/README.md +66 -0
  8. package/templates/phaser-game/agent/acceptance.md +16 -0
  9. package/templates/phaser-game/agent/brief.md +20 -0
  10. package/templates/phaser-game/agent/game-manifest.json +41 -0
  11. package/templates/phaser-game/index.html +12 -0
  12. package/templates/phaser-game/mpgd.targets.json +42 -0
  13. package/templates/phaser-game/package.json +35 -0
  14. package/templates/phaser-game/src/env.d.ts +12 -0
  15. package/templates/phaser-game/src/game/state.ts +29 -0
  16. package/templates/phaser-game/src/i18n/messages.ts +64 -0
  17. package/templates/phaser-game/src/main.ts +78 -0
  18. package/templates/phaser-game/src/platform/gameServices.ts +70 -0
  19. package/templates/phaser-game/src/platform/installPlatform.ts +96 -0
  20. package/templates/phaser-game/src/platform/runtimeDetector.ts +30 -0
  21. package/templates/phaser-game/src/runtime/createGame.ts +28 -0
  22. package/templates/phaser-game/src/runtime/gameContext.ts +18 -0
  23. package/templates/phaser-game/src/runtime/id.ts +6 -0
  24. package/templates/phaser-game/src/scenes/BootScene.ts +11 -0
  25. package/templates/phaser-game/src/scenes/LobbyScene.ts +65 -0
  26. package/templates/phaser-game/src/scenes/PlayScene.ts +121 -0
  27. package/templates/phaser-game/src/styles.css +31 -0
  28. package/templates/phaser-game/tsconfig.json +27 -0
  29. package/templates/phaser-game/vite.config.ts +35 -0
@@ -0,0 +1,28 @@
1
+ import Phaser from 'phaser';
2
+
3
+ import { BootScene } from '../scenes/BootScene';
4
+ import { LobbyScene } from '../scenes/LobbyScene';
5
+ import { PlayScene } from '../scenes/PlayScene';
6
+ import { starterContextKey, type StarterContext } from './gameContext';
7
+
8
+ export function createStarterGame(input: {
9
+ readonly mountId: string;
10
+ readonly context: StarterContext;
11
+ }): Phaser.Game {
12
+ const game = new Phaser.Game({
13
+ type: Phaser.AUTO,
14
+ parent: input.mountId,
15
+ width: 960,
16
+ height: 540,
17
+ backgroundColor: '#07111f',
18
+ scale: {
19
+ mode: Phaser.Scale.FIT,
20
+ autoCenter: Phaser.Scale.CENTER_BOTH,
21
+ },
22
+ scene: [BootScene, LobbyScene, PlayScene],
23
+ });
24
+
25
+ game.registry.set(starterContextKey, input.context);
26
+
27
+ return game;
28
+ }
@@ -0,0 +1,18 @@
1
+ import type { AnalyticsReporter, BufferedAnalyticsSink } from '@mpgd/analytics';
2
+ import type { Locale } from '@mpgd/i18n';
3
+ import type { PlayerIdentity } from '@mpgd/platform';
4
+ import type { TargetConfiguredGateway, TargetRuntimeSnapshot } from '@mpgd/target-config';
5
+
6
+ import type { StarterGameServices } from '../platform/gameServices';
7
+
8
+ export const starterContextKey = 'starterContext';
9
+
10
+ export interface StarterContext {
11
+ readonly platform: TargetConfiguredGateway;
12
+ readonly runtime: TargetRuntimeSnapshot;
13
+ readonly player: PlayerIdentity;
14
+ readonly locale: Locale;
15
+ readonly gameServices: StarterGameServices;
16
+ readonly analytics: AnalyticsReporter;
17
+ readonly analyticsSink: BufferedAnalyticsSink;
18
+ }
@@ -0,0 +1,6 @@
1
+ export function createClientId(prefix: string): string {
2
+ const randomUuid = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
3
+ const id = randomUuid?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
4
+
5
+ return `${prefix}-${id}`;
6
+ }
@@ -0,0 +1,11 @@
1
+ import Phaser from 'phaser';
2
+
3
+ export class BootScene extends Phaser.Scene {
4
+ constructor() {
5
+ super('BootScene');
6
+ }
7
+
8
+ create(): void {
9
+ this.scene.start('LobbyScene');
10
+ }
11
+ }
@@ -0,0 +1,65 @@
1
+ import Phaser from 'phaser';
2
+
3
+ import { t } from '../i18n/messages';
4
+ import { starterContextKey, type StarterContext } from '../runtime/gameContext';
5
+
6
+ export class LobbyScene extends Phaser.Scene {
7
+ constructor() {
8
+ super('LobbyScene');
9
+ }
10
+
11
+ create(): void {
12
+ const rawContext = this.registry.get(starterContextKey);
13
+
14
+ if (rawContext === undefined || rawContext === null) {
15
+ throw new Error('Starter context is missing from Phaser registry.');
16
+ }
17
+
18
+ const context = rawContext as StarterContext;
19
+ const locale = context.locale;
20
+ const features = Object.values(context.runtime.features)
21
+ .filter((feature) => feature.enabled)
22
+ .map((feature) => feature.feature)
23
+ .join(', ') || t(locale, 'featuresNone');
24
+
25
+ this.add
26
+ .text(480, 106, t(locale, 'title'), {
27
+ color: '#ffffff',
28
+ fontFamily: 'Arial, sans-serif',
29
+ fontSize: '40px',
30
+ fontStyle: 'bold',
31
+ })
32
+ .setOrigin(0.5);
33
+ this.add
34
+ .text(480, 174, t(locale, 'target', { target: context.runtime.configTarget }), {
35
+ color: '#9fb3c8',
36
+ fontFamily: 'Arial, sans-serif',
37
+ fontSize: '20px',
38
+ })
39
+ .setOrigin(0.5);
40
+ this.add
41
+ .text(480, 216, t(locale, 'features', { features }), {
42
+ color: '#d6dee8',
43
+ fontFamily: 'Arial, sans-serif',
44
+ fontSize: '18px',
45
+ })
46
+ .setOrigin(0.5);
47
+ this.add
48
+ .text(480, 258, t(locale, 'backend', { mode: context.gameServices.mode }), {
49
+ color: context.gameServices.mode === 'disabled' ? '#f59e0b' : '#2dd4bf',
50
+ fontFamily: 'Arial, sans-serif',
51
+ fontSize: '18px',
52
+ })
53
+ .setOrigin(0.5);
54
+ this.add
55
+ .text(480, 336, t(locale, 'tapToStart'), {
56
+ color: '#ffffff',
57
+ fontFamily: 'Arial, sans-serif',
58
+ fontSize: '22px',
59
+ })
60
+ .setOrigin(0.5);
61
+
62
+ this.input.once('pointerdown', () => this.scene.start('PlayScene'));
63
+ this.input.keyboard?.once('keydown-ENTER', () => this.scene.start('PlayScene'));
64
+ }
65
+ }
@@ -0,0 +1,121 @@
1
+ import Phaser from 'phaser';
2
+
3
+ import type { LogicalAdPlacementId } from '@mpgd/platform';
4
+
5
+ import { createStarterRunState, stepStarterRunState, type StarterRunState } from '../game/state';
6
+ import { t } from '../i18n/messages';
7
+ import { starterContextKey, type StarterContext } from '../runtime/gameContext';
8
+ import { createClientId } from '../runtime/id';
9
+
10
+ const rewardedPlacementId = 'CONTINUE_AFTER_FAIL' satisfies LogicalAdPlacementId;
11
+
12
+ export class PlayScene extends Phaser.Scene {
13
+ private state: StarterRunState = createStarterRunState();
14
+ private marker!: Phaser.GameObjects.Arc;
15
+ private scoreText!: Phaser.GameObjects.Text;
16
+ private rewardText!: Phaser.GameObjects.Text;
17
+ private analyticsText!: Phaser.GameObjects.Text;
18
+ private context!: StarterContext;
19
+ private lastScore = -1;
20
+ private lastAnalyticsCount = -1;
21
+
22
+ constructor() {
23
+ super('PlayScene');
24
+ }
25
+
26
+ create(): void {
27
+ this.context = this.registry.get(starterContextKey) as StarterContext;
28
+
29
+ this.marker = this.add.circle(480, 250, 34, 0x2dd4bf);
30
+ this.scoreText = this.add
31
+ .text(480, 86, '', {
32
+ color: '#ffffff',
33
+ fontFamily: 'Arial, sans-serif',
34
+ fontSize: '28px',
35
+ })
36
+ .setOrigin(0.5);
37
+ this.rewardText = this.add
38
+ .text(480, 412, this.rewardHint(), {
39
+ color: '#d6dee8',
40
+ fontFamily: 'Arial, sans-serif',
41
+ fontSize: '18px',
42
+ })
43
+ .setOrigin(0.5);
44
+ this.analyticsText = this.add
45
+ .text(480, 454, '', {
46
+ color: '#9fb3c8',
47
+ fontFamily: 'Arial, sans-serif',
48
+ fontSize: '16px',
49
+ })
50
+ .setOrigin(0.5);
51
+
52
+ this.input.keyboard?.on('keydown-R', () => {
53
+ void this.requestRewardedAd();
54
+ });
55
+ }
56
+
57
+ override update(_time: number, delta: number): void {
58
+ this.state = stepStarterRunState(this.state, delta);
59
+
60
+ const angle = this.state.phase * Math.PI * 2;
61
+ this.marker.setPosition(480 + Math.cos(angle) * 144, 250 + Math.sin(angle) * 54);
62
+
63
+ if (this.state.score !== this.lastScore) {
64
+ this.lastScore = this.state.score;
65
+ this.scoreText.setText(t(this.context.locale, 'score', { score: this.state.score }));
66
+ }
67
+
68
+ const analyticsCount = this.context.analyticsSink.events.length;
69
+
70
+ if (analyticsCount !== this.lastAnalyticsCount) {
71
+ this.lastAnalyticsCount = analyticsCount;
72
+ this.analyticsText.setText(t(this.context.locale, 'analytics', { count: analyticsCount }));
73
+ }
74
+ }
75
+
76
+ private rewardHint(): string {
77
+ const rewardedAds = this.context.runtime.features.rewardedAds;
78
+
79
+ if (!rewardedAds.enabled) {
80
+ return t(this.context.locale, 'rewardUnavailable');
81
+ }
82
+
83
+ return t(this.context.locale, 'rewardPending');
84
+ }
85
+
86
+ private async requestRewardedAd(): Promise<void> {
87
+ const rewardedAds = this.context.runtime.features.rewardedAds;
88
+
89
+ if (!rewardedAds.enabled) {
90
+ this.rewardText.setText(t(this.context.locale, 'rewardUnavailable'));
91
+ return;
92
+ }
93
+
94
+ try {
95
+ const result = await this.context.platform.ads.showRewarded({
96
+ placementId: rewardedPlacementId,
97
+ idempotencyKey: createClientId('starter-reward'),
98
+ });
99
+
100
+ await this.context.analytics.track({
101
+ name: result.status === 'completed' ? 'rewarded_ad_completed' : 'rewarded_ad_rejected',
102
+ properties: {
103
+ status: result.status,
104
+ granted: result.rewardGranted,
105
+ },
106
+ });
107
+
108
+ this.rewardText.setText(t(this.context.locale, 'reward', { status: result.status }));
109
+ } catch (error) {
110
+ await this.context.analytics.track({
111
+ name: 'rewarded_ad_rejected',
112
+ properties: {
113
+ status: 'failed',
114
+ granted: false,
115
+ },
116
+ });
117
+ this.rewardText.setText(t(this.context.locale, 'rewardError'));
118
+ console.error('[rewarded-ad]', error);
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,31 @@
1
+ html,
2
+ body,
3
+ #game {
4
+ width: 100%;
5
+ height: 100%;
6
+ margin: 0;
7
+ }
8
+
9
+ body {
10
+ overflow: hidden;
11
+ background: #07111f;
12
+ color: #ffffff;
13
+ font-family: Arial, sans-serif;
14
+ }
15
+
16
+ canvas {
17
+ display: block;
18
+ }
19
+
20
+ .boot-error {
21
+ box-sizing: border-box;
22
+ width: min(720px, calc(100vw - 32px));
23
+ margin: 20vh auto 0;
24
+ padding: 20px;
25
+ border: 1px solid #dc2626;
26
+ border-radius: 8px;
27
+ background: #1f1111;
28
+ color: #fecaca;
29
+ font-size: 16px;
30
+ line-height: 1.5;
31
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ __TSCONFIG_EXTENDS_LINE__
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "useDefineForClassFields": true,
6
+ "strict": true,
7
+ "strictNullChecks": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "exactOptionalPropertyTypes": true,
10
+ "noImplicitOverride": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "skipLibCheck": true,
13
+ "module": "ESNext",
14
+ "moduleResolution": "Bundler",
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "types": ["vite/client", "node"],
18
+ "noEmit": true
19
+ },
20
+ "include": [
21
+ "src/**/*.ts",
22
+ "vite.config.ts"__TSCONFIG_WORKSPACE_INCLUDES__
23
+ ],
24
+ "exclude": [
25
+ "dist"__TSCONFIG_WORKSPACE_EXCLUDES__
26
+ ]
27
+ }
@@ -0,0 +1,35 @@
1
+ import ttsc from '@ttsc/unplugin/vite';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig(({ mode }) => {
5
+ const isProduction = mode === 'production';
6
+
7
+ return {
8
+ plugins: [
9
+ ttsc({
10
+ project: 'tsconfig.json',
11
+ plugins: false,
12
+ }),
13
+ ],
14
+ define: {
15
+ __APP_TARGET__: JSON.stringify(process.env.APP_TARGET ?? 'browser'),
16
+ __APP_VERSION__: JSON.stringify(process.env.APP_VERSION ?? '0.0.0-dev'),
17
+ __BUILD_ID__: JSON.stringify(process.env.BUILD_ID ?? 'local'),
18
+ __DEBUG_BUILD__: JSON.stringify(!isProduction),
19
+ },
20
+ build: {
21
+ target: 'es2022',
22
+ sourcemap: !isProduction,
23
+ outDir: 'dist',
24
+ assetsDir: 'assets',
25
+ emptyOutDir: true,
26
+ rolldownOptions: {
27
+ output: {
28
+ entryFileNames: 'assets/game.js',
29
+ chunkFileNames: 'assets/[name].js',
30
+ assetFileNames: 'assets/[name][extname]',
31
+ },
32
+ },
33
+ },
34
+ };
35
+ });