From 5caf5a9cec985e913bf4537797ad59ab0e3f67e5 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 11 Aug 2017 18:16:11 -0700 Subject: [PATCH 1/3] Initial thoughts --- mixer.d.ts | 222 +++++++++++++++++++++++++- package.json | 1 + src/alchemy/Control.ts | 208 ++++++++++++++++-------- src/alchemy/Preact.tsx | 92 +++++++++++ src/alchemy/State.ts | 350 +++++++++++++++++++++++++++++++++++++++++ src/alchemy/index.ts | 3 + src/index.tsx | 27 ++-- tsconfig.json | 15 +- 8 files changed, 837 insertions(+), 81 deletions(-) create mode 100644 src/alchemy/Preact.tsx create mode 100644 src/alchemy/State.ts create mode 100644 src/alchemy/index.ts diff --git a/mixer.d.ts b/mixer.d.ts index 54933de..f1cf5d9 100644 --- a/mixer.d.ts +++ b/mixer.d.ts @@ -1,7 +1,225 @@ declare module 'mixer' { + import { EventEmitter } from 'eventemitter3'; + + /** + * IControl is some kind of control on the protocol. The controlID is + * unique in the scene. + * + * This is a minimal interface: control types may extend this interface + * and define their own properties. + */ + export interface IControl { + readonly controlID: string; + readonly kind: string; + } + + /** + * IScene is a scene on the protocol. It can contain many controls. The + * sceneID is globally unique. + * + * This is a minimal interface: scenes may extend this interface + * and define their own properties. + */ + export interface IScene { + readonly sceneID: string; + readonly controls: IControl[]; + } + + /** + * IGroup is a groups of participants on the protocol. Groups are assigned + * to a single scene. + * + * This is a minimal interface: integrations may extend this interface + * and define their own properties. + */ + export interface IGroup { + readonly sceneID: string; + readonly groupID: string; + } + + /** + * ISceneCreate is an event triggered when a new scene is created. + */ + export interface ISceneCreate { + readonly scenes: IScene[]; + } + + /** + * ISceneUpdate is an event triggered when a an existing scene is updated. + */ + export interface ISceneUpdate { + readonly scenes: IScene[]; + } + + /** + * ISceneDelete is an event triggered when a scene is deleted. + */ + export interface ISceneDelete { + readonly sceneID: string; + readonly reassignSceneID: string; + } + + /** + * IControlChange is fired when new controls are created, updated, or + * deleted in the scene. + */ + export interface IControlChange { + readonly sceneID: string; + readonly controls: IControl[]; + } + + /** + * IGroupDelete is an event triggered when a group is deleted. + */ + export interface IGroupDelete { + readonly groupID: string; + readonly reassignGroupID: string; + } + + /** + * IGroupCreate is fired when new groups are created. + */ + export interface IGroupCreate { + readonly groups: IGroup[]; + } + + /** + * IGroupUpdate is fired when groups are updated. + */ + export interface IGroupUpdate { + readonly groups: IGroup[]; + } + + /** + * IParticipant represents a user in Interactive. As far as controls are + * concerned, this means only the current user. + * + * This is a minimal interface: integrations may extend this interface + * and define their own properties. + */ + export interface IParticipant { + readonly sessionID: string; + readonly userID: number; + readonly username: string; + readonly level: number; + readonly lastInputAt: number; // milliseconds timestamp + readonly connectedAt: number; // milliseconds timestamp + readonly disabled: boolean; + readonly groupID: string; + } + + /** + * IParticipantUpdate is fired when the participant's data is updated, + * and once when first connecting. + */ + export interface IParticipantUpdate { + readonly participants: [IParticipant]; + } + + /** + * IInput is an input event fired on a control. This is a minimal + * interface; custom properties may be added and they will be passed + * through to the game client. + */ + export interface IInput { + controlID: string; + event: string; + } + + /** + * IReady is sent when when the integration indicates that it has set up + * and is ready to accept input. + */ + export interface IReady { + readonly isReady: boolean; + } /** * Attaches a handler function that will be triggered when the call comes in. */ - export function on(call: string, data: any): void; -} \ No newline at end of file + export class Socket extends EventEmitter { + on(event: 'onParticipantJoin', handler: (ev: IParticipantUpdate) => void): this; + on(event: 'onParticipantUpdate', handler: (ev: IParticipantUpdate) => void): this; + on(event: 'onGroupCreate', handler: (ev: IGroupCreate) => void): this; + on(event: 'onGroupDelete', handler: (ev: IGroupDelete) => void): this; + on(event: 'onGroupUpdate', handler: (ev: IGroupUpdate) => void): this; + on(event: 'onSceneCreate', handler: (ev: ISceneCreate) => void): this; + on(event: 'onSceneDelete', handler: (ev: ISceneDelete) => void): this; + on(event: 'onSceneUpdate', handler: (ev: ISceneUpdate) => void): this; + on(event: 'onControlCreate', handler: (ev: IControlChange) => void): this; + on(event: 'onControlDelete', handler: (ev: IControlChange) => void): this; + on(event: 'onControlUpdate', handler: (ev: IControlChange) => void): this; + on(event: 'onReady', handler: (ev: IReady) => void): this; + + call(method: 'giveInput', options: IInput): void; + call(method: string, options: object): Promise; + call(method: string, options: object, waitForReply: true): Promise; + call(method: string, options: object, waitForReply: false): void; + } + + /** + * IVideoPositionOptions are passed into display.moveVideo() to change + * where the video is shown on the screen. + */ + export interface IVideoPositionOptions { + /** + * Position of the video on screen as a percent (0 - 100) of the screen width. + * If omitted, it's not modified. + */ + x?: number; + + /** + * Position of the video on screen as a percent (0 - 100) of the screen height. + * If omitted, it's not modified. + */ + y?: number; + + /** + * Width of the video on screen as a percent (0 - 100) of the screen. + * If omitted, it's not modified. + */ + width?: number; + + /** + * Height of the video on screen as a percent (0 - 100) of the screen. + * If omitted, it's not modified. + */ + height?: number; + + /** + * Duration of the movement easing in milliseconds. Defaults to 0. + */ + duration?: number; + + /** + * CSS easing function. Defaults to 'linear'. + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function + */ + easing?: string; + } + + /** + * Display modified the display of interactive controls. + */ + export class Display { + /** + * Hides the controls and displays a loading spinner, optionally + * with a custom message. This is useful for transitioning. If called + * while the controls are already minimized, it will update the message. + */ + minimize(message?: string): void; + + /** + * Restores previously minimize()'d controls. + */ + maximize(): void; + + /** + * Moves the position of the video on the screen. + */ + moveVideo(options: IVideoPositionOptions): void; + } + + export const socket: Socket; + export const display: Display; +} diff --git a/package.json b/package.json index e181bb8..2f4a968 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "author": "Connor Peet ", "license": "MIT", "dependencies": { + "eventemitter3": "^2.0.3", "preact": "^8.2.1" }, "devDependencies": { diff --git a/src/alchemy/Control.ts b/src/alchemy/Control.ts index 247b5de..d51d0b0 100644 --- a/src/alchemy/Control.ts +++ b/src/alchemy/Control.ts @@ -5,9 +5,57 @@ import { Component } from 'preact'; * the control is rendered. */ export interface IControlOptions { + /** + * The kind of the control that this class should render. The default + * kinds are "button" and "joystick". + */ + kind: string; +} + +/** + * ISceneOptions can be passed into the @Scene decorator. + */ +export interface ISceneOptions { + /** + * Whether to use this scene as the handler for all scenes. + * + * You can override scenes by their `id` to use a different scene for a + * certain sceneID. In cases where there isn't a specific class for a + * sceneID, the default will be used. + * + * ``` + * @Scene({ default: true }) + * class MyAwesomeScene { + * // ... + * } + * ``` + */ + default?: true; + /** + * When specified, registers this class to handle a specific scene ID. + * For instance, if you wanted the scenes `lobby` and `arena` to have + * two different scenes, you could do that with something like the + * following: + * + * ``` + * @Scene({ id: 'lobby' }) + * class Lobbby { + * // ... + * } + * + * @Scene({ id: 'arena' }) + * class Arena { + * // ... + * } + * ``` + */ + id?: string; } +/** + * IInputOptions are passed into the @Input decorator. + */ export interface IInputOptions { /** * Alias of the property as sent to the Interactive game client and sent @@ -16,93 +64,123 @@ export interface IInputOptions { alias?: string; } -/** - * OnDestroy is an interface that controls who want to be notified before being - * destroyed may implement. - * - * Note: this is primarily for use in non-Preact based controls. Preact controls - * can use the built-in `componentWillUnmount` lifecycle hook instead. - */ -export interface OnDestroy { - mxOnDestroy(); +export interface ISceneDescriptor extends ISceneOptions { + ctor: Function; } -/** - * OnChanges is an interface that controls who want to be notified when their - * inputs update may implement. - * - * Note: this is primarily for use in non-Preact based controls. Preact controls - * can use the built-in `componentWillReceiveProps` lifecycle hook instead. - */ -export interface OnChanges { - mxOnChanges(changes: Changes); + +export interface IInputDescriptor extends IInputOptions { + propertyName: string; } + +export interface IControlDescriptor extends IControlOptions { + ctor: Function; + inputs: IInputDescriptor[]; +} + +export const sceneRegistry: ISceneDescriptor[] = []; + /** - * Changes is the interface passed into the OnChanges lifecycle hook. + * Scene is a decorator you can use to designate a class as a Scene. See + * documentation on {@link ISceneOptions} for more info. */ -export interface Changes { - [key: string]: { - previousValue: any; - nextValue: any; +export function Scene(options: ISceneOptions = { default: true }) { + return (ctor: Function) => { + const existingId = options.id && sceneRegistry.find(s => s.id === options.id); + if (existingId) { + throw new Error( + `Duplicate scene IDs registered! Both ${existingId.ctor.name} and ` + + `${ctor.name} registered themselves for scene ID ${options.id}` + ); + } + + const existingDefault = options.default && sceneRegistry.findIndex(s => s.default); + const descriptor: ISceneDescriptor = { ...options, ctor }; + if (existingDefault !== -1) { + sceneRegistry[existingDefault] = descriptor; + } else { + sceneRegistry.push(descriptor); + } }; } +export const controlRegistry: IControlDescriptor[] = []; + /** - * @private + * Scene is a decorator you can use to designate a class as a Scene. See + * documentation on {@link IControlOptions} for more info. */ -interface InputDescriptor { - /** - * Property name on the class - */ - propertyName: string; +export function Control(options: IControlOptions) { + return (ctor: Function) => { + const existing = controlRegistry.find(c => c.kind === options.kind); + if (existing) { + throw new Error( + `Duplicate controls registered! Both ${existing.ctor.name} and ` + + `${ctor.name} registered themselves for control kind ${options.kind}` + ); + } - /** - * Name of the property on the protocol. - */ - remoteName: string; + controlRegistry.push({ ...options, ctor, inputs: [] }); + }; +} + +function registerInput(options: IInputOptions, target: object, propertyName: string) { + const control = controlRegistry.find(c => target instanceof c.ctor); + if (!control) { + throw new Error( + `Tried to register input ${target.constructor.name}.${propertyName}` + + `but ${target.constructor.name} isn't a control! Did you forget ` + + `a @Control() decorator?` + ); + } + + control.inputs.push({ + alias: propertyName, + ...options, + propertyName, + }); } /** - * @private + * Creates setters for the property on the target so that the target's + * state is updated whenever the property is set. This is used so that + * @Input() can work automagically in a Preact environment. */ -interface ControlDescriptor { - /** - * The component class of the control. - */ - readonly component: new (options: any) => any; +function createPreactSetters(target: IMaybePreact, propertyName: string) { + let value: T = ( target)[propertyName]; + Object.defineProperty(target, propertyName, { + enumerable: true, + get() { + return value; + }, + set(next: T) { + value = next; + target.setState({ ...target.state, propertyName: next }); + } + }) +} - /** - * A list of inputs the control takes. - */ - readonly inputs: InputDescriptor[]; +interface IMaybePreact { + setState(obj: any): void; + state: any; } /** - * registry is the global control registry populated by the @Control decorator - * whenever it decorates a class. + * @Input decorates a property on a control. It makes it configurable in the + * Interactive studio and settable for Preact components. */ -export const registry: ControlDescriptor[] = {}; +export function Input(options: IInputOptions = {}) { + let registered = false; -export function Control(options: IControlOptions) { - return (constructor: Function) => { + return (target: object, propertyName: string) => { + if (!registered) { // only register the first time the class is instantiated + registerInput(options, target, propertyName); + registered = true; + } + if (typeof ( target).setState === 'function') { + createPreactSetters( target, propertyName); + } }; } - -export function Input(options?: IInputOptions) { - return function (target: Function, property: string) { - const control = registry.find(d => d.component === target); - if (!control) { - throw new Error( - `@Input ${target.name}.${property} was registered, but ${target.name} ` + - `does not have a @Control decorator!` - ); - } - - control.inputs.push({ - propertyName: property, - remoteName: options.alias || property, - }); - } -} \ No newline at end of file diff --git a/src/alchemy/Preact.tsx b/src/alchemy/Preact.tsx new file mode 100644 index 0000000..16c64ed --- /dev/null +++ b/src/alchemy/Preact.tsx @@ -0,0 +1,92 @@ +import { Scene } from './Control'; +import { MControl, MScene, State } from './State'; +import { Component, h, render } from 'preact'; +import * as mixer from 'mixer'; + +/** + * PreactScene is the base scene. You can extend and override this scene. + */ +@Scene({ default: true }) +export class PreactScene extends Component<{ scene: MScene }, T & mixer.IScene> { + protected readonly scene: MScene; + + constructor(props: { scene: MScene }, attributes: T) { + super(props, attributes); + this.scene = props.scene; + this.scene.on('update', ev => { + this.setState(Object.assign({}, this.state, ev)); + }); + } + + public render() { + return
+ {this.getAllControls()} +
; + } + + /** + * Returns an array of all controls, that can be injected in render(). + */ + protected getAllControls() { + return this.state.controls.map(this.getSceneComponent); + } + + /** + * Returns the renderable component for a control. + */ + protected getSceneComponent(control: MControl) { + const El = control.descriptor().ctor as typeof PreactControl; + return ; + } +} + +/** + * PreactStage is the bootstrap component for the interactive integration. + * You may swap this out or customize it if you want, but it shouldn't + * generally be necessary. + */ +export class PreactStage extends Component { + private interactive = new State(); + + constructor() { + super(); + this.interactive.participant.on('groupUpdate', ev => { + if (ev.sceneID !== this.state.scene.sceneID) { + this.setState({ ...this.state, scene: this.interactive.scenes[ev.sceneID] }); + } + }); + } + + public render() { + return
{this.getSceneComponent(this.state.scene)}
; + } + + + /** + * Returns the renderable component for the scene. + */ + protected getSceneComponent(scene: MScene) { + const El = scene.descriptor().ctor as typeof PreactScene; + return ; + } +} + +/** + * PreactControl is the "primitve" control that you can extend to implement + * your own control types. Make sure to decorate your extensions with @Control + * to register them! Check out our built-in Joystick and Button types for + * some examples. + */ +export abstract class PreactControl extends Component<{ control: MControl }, T & mixer.IControl> { + protected readonly control: MControl; + + constructor(props: { control: MControl }, attributes: T) { + super(props, attributes); + this.control = props.control; + this.control.on('update', ev => { + this.setState(Object.assign({}, this.state, ev)); + }); + + Object.defineProperty + } +} diff --git a/src/alchemy/State.ts b/src/alchemy/State.ts new file mode 100644 index 0000000..16bca80 --- /dev/null +++ b/src/alchemy/State.ts @@ -0,0 +1,350 @@ +import { controlRegistry, IControlDescriptor, ISceneDescriptor, sceneRegistry } from './Control'; +import { EventEmitter } from 'eventemitter3'; +import { Component } from 'preact'; +import * as mixer from 'mixer'; + +/** + * The Registery is the system that manages the lifecycle of interactive + * controls and scenes. + */ +export class State extends EventEmitter { + /** + * Map of scene IDs to Scene objects. + */ + public readonly scenes: { [id: string]: MScene } = Object.create(null); + + /** + * Map of group IDs to Group objects. + */ + public readonly groups: { [id: string]: Group } = Object.create(null); + + /** + * The current user connected to interactive. Note that + */ + public readonly participant = new Participant(); + + /** + * Whether the game client is ready to accept input. The `ready` event will + * fire when this changes. + */ + public readonly isReady = false; + + constructor() { + super(); + + // scenes ------------------------------- + mixer.socket.on('onSceneCreate', ({ scenes }) => { + scenes.forEach(s => { + const scene = this.scenes[s.sceneID] = new MScene(this, s); + this.emit('sceneCreate', scene); + }); + }); + mixer.socket.on('onSceneUpdate', ({ scenes }) => { + scenes.forEach(s => ( this.scenes[s.sceneID]).update(s)); + }); + mixer.socket.on('onSceneDelete', packet => { + this.emit('sceneDelete', this.scenes[packet.sceneID], packet); + this.scenes[packet.sceneID].emit('delete', packet); + delete this.scenes[packet.sceneID]; + }); + + // groups ------------------------------- + mixer.socket.on('onGroupCreate', ({ groups }) => { + groups.forEach(g => { + const group = this.groups[g.groupID] = + new Group(this.scenes[g.sceneID], g); + this.emit('groupCreate', group); + }); + }); + mixer.socket.on('onGroupUpdate', ({ groups }) => { + groups.forEach(s => { + ( this.groups[s.groupID]).update(s); + if (this.participant.groupID === s.groupID) { + this.participant.emit('groupUpdate', s); + } + }); + }); + mixer.socket.on('onGroupDelete', packet => { + this.emit('groupDelete', this.groups[packet.groupID], packet); + this.groups[packet.groupID].emit('delete', packet); + delete this.groups[packet.groupID]; + }); + + // global state ------------------------- + mixer.socket.on('onParticipantUpdate', ev => { + ( this.participant).update(ev.participants[0]); + }); + mixer.socket.on('onReady', ev => { + ( this).isReady = ev.isReady; + this.emit('ready', ev.isReady); + }); + } + + public on(event: 'groupCreate', handler: (group: Group) => void): this; + public on(event: 'groupDelete', handler: (group: Group, ev: mixer.IGroupDelete) => void): this; + public on(event: 'sceneCreate', handler: (scene: MScene) => void): this; + public on(event: 'sceneDelete', handler: (scene: MScene, ev: mixer.ISceneDelete) => void): this; + public on(event: 'ready', handler: (isReady: boolean) => void): this; + public on(event: string, handler: (...args: any[]) => void): this { + return super.on(event, handler); + } + + public once(event: 'ready', handler: (isReady: boolean) => void): this; + public once(event: string, handler: (...args: any[]) => void): this { + return super.once(event, handler); + } +} + +/** + * Group is a group of participants that is assigned to a specific scene. + */ +export class Group extends EventEmitter { + /** + * The scene this group is currently assigned to. + */ + public readonly scene: MScene; + + /** + * The state this group is assigned to. + */ + public readonly state: State; + + constructor(scene: MScene, private props: mixer.IGroup) { + super(); + this.scene = scene; + this.state = scene.state; + } + + /** + * Gets a custom property from the group. + */ + public get(prop: string, defaultValue: T): T { + const props: any = this.props; + return props[prop] === undefined ? defaultValue : props[prop]; + } + + public on(event: 'delete', handler: (ev: mixer.ISceneDelete) => void): this; + public on(event: 'update', handler: (ev: mixer.IScene) => void): this; + public on(event: string, handler: (...args: any[]) => void): this { + return super.on(event, handler); + } + + private update(opts: mixer.IGroup) { + this.props = opts; + this.emit('update', opts); + } +} + +/** + * The Participant is the current user connected to Interactive. + * + * Note that when the controls are first created, all its properties will + * be empty; you'll want to wait on the first `update`, or the Scene's `ready` + * event, before propogating anything. + */ +export class Participant extends EventEmitter { + private props: mixer.IParticipant; + + /** + * The State this participant belongs to. + */ + public readonly state: State; + + /** + * The Group this user belongs to. + */ + public readonly group: Group; + + /** + * The user's unique ID for this interactive session. + */ + public readonly sessionID: string; + + /** + * The users's ID on Mixer. + */ + public readonly userID: number; + + /** + * The user's Mixer username + */ + public readonly username: string; + + /** + * the users's level. + */ + public readonly level: number; + + /** + * Whether the game client has disabled this user's input. + */ + public readonly disabled: boolean; + + /** + * The group ID this participant belongs to. + */ + public readonly groupID: string; + + /** + * Gets a custom property from the participant. + */ + public get(prop: string, defaultValue: T): T { + const props: any = this.props; + return props[prop] === undefined ? defaultValue : props[prop]; + } + + public on(event: 'groupUpdate', handler: (ev: mixer.IGroup) => void): this; + public on(event: 'update', handler: (ev: mixer.IParticipant) => void): this; + public on(event: string, handler: (...args: any[]) => void): this { + return super.on(event, handler); + } + + private update(props: mixer.IParticipant) { + const p: any = this; + p.group = this.state.groups[props.groupID]; + p.disabled = props.disabled; + p.groupID = props.groupID; + this.props = props; + this.emit('update', props); + } +} + +/** + * Scene holds a group of controls. User groups can be assigned to a scene. + */ +export class MScene extends EventEmitter { + /** + * The scene's ID. + */ + public readonly sceneID: string; + + /** + * Map of control IDs to Control objects. + */ + public readonly controls: { [id: string]: MControl } = Object.create(null); + + /** + * The State this scene belongs to. + */ + public readonly state: State; + + constructor(state: State, private props: mixer.IScene) { + super(); + this.state = state; + this.sceneID = props.sceneID; + props.controls.forEach(c => { + this.controls[c.controlID] = new MControl(this, c); + }); + } + + /** + * Returns the constructor class for this scene, the class decoratored + * with `@Scene`. + */ + public descriptor(): ISceneDescriptor { + const specific = sceneRegistry.find(s => s.id === this.sceneID); + if (specific) { + return specific; + } + + const generic = sceneRegistry.find(s => s.default); + if (generic) { + return generic; + } + + throw new Error( + `Could not find a specific scene for ${this.sceneID}, and no default ` + + `scene was registered. Did you forget to add @Scene({ id: '${this.sceneID}' }) ` + + `to one of your classes?` + ); + } + + /** + * Gets a custom property from the scene. + */ + public get(prop: string, defaultValue: T): T { + const props: any = this.props; + return props[prop] === undefined ? defaultValue : props[prop]; + } + + public on(event: 'delete', handler: (ev: mixer.ISceneDelete) => void): this; + public on(event: 'update', handler: (ev: mixer.IScene) => void): this; + public on(event: string, handler: (...args: any[]) => void): this { + return super.on(event, handler); + } + + private update(opts: mixer.IScene) { + this.props = opts; + this.emit('update', opts); + } +} + +/** + * Control is a control type in the scene. + */ +export class MControl extends EventEmitter { + /** + * Unique ID of the control in the scene. + */ + public readonly controlID: string; + + /** + * The kind of this control. + */ + public readonly kind: string; + + /** + * The Scene this control belongs to. + */ + public readonly scene: MScene; + + /** + * The State this control belongs to. + */ + public readonly state: State; + + constructor(scene: MScene, private props: mixer.IControl) { + super(); + this.controlID = props.controlID; + this.kind = props.kind; + this.scene = scene; + } + + /** + * Gets a custom property from the control. + */ + public get(prop: string, defaultValue: T): T { + const props: any = this.props; + return props[prop] === undefined ? defaultValue : props[prop]; + } + + /** + * Returns the constructor class for this control, the class decoratored + * with `@Control`. + */ + public descriptor(): IControlDescriptor { + const descriptor = controlRegistry.find(s => s.kind === this.kind); + if (descriptor) { + return descriptor; + } + + throw new Error( + `Could not find a control kind for ${this.kind}. Did you forget to ` + + `add @Control({ kind: '${this.kind}' }) to one of your classes?` + ); + } + + /** + * giveInput sends input on this control up to the Interactive service + * and back down to the game client. + */ + public giveInput>(input: T) { + input.controlID = this.controlID; + mixer.socket.call('giveInput', input); + } + + private update(opts: mixer.IControl) { + this.props = opts; + this.emit('update', opts); + } +} diff --git a/src/alchemy/index.ts b/src/alchemy/index.ts new file mode 100644 index 0000000..3d1b978 --- /dev/null +++ b/src/alchemy/index.ts @@ -0,0 +1,3 @@ +export { Input, ISceneOptions, Scene } from './Control'; +export * from './Control'; +export * from './Preact'; diff --git a/src/index.tsx b/src/index.tsx index 706a038..e10f93a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,19 +1,24 @@ -import { Component, h, render } from 'preact'; +import { Control, Input, PreactControl, PreactScene, PreactStage, Scene } from './alchemy'; +import { h, render } from 'preact'; -interface HelloWorldProps { - name: string -} +@Control({ kind: 'button' }) +export class Button extends PreactControl<{ pressed: boolean }> { + @Input() + public text: string; -class HelloWorld extends Component { - private canvas: Element; + public render() { + return + } - render (props) { - return this.canvas = c}> + protected mousedown() { + this.control.giveInput({ event: 'mousedown' }) } - public componentDidMount() { - debugger; + protected mouseup() { + this.control.giveInput({ event: 'mousedown' }) } } -render(, document.querySelector('#app')); \ No newline at end of file +render(, document.querySelector('#app')); + + diff --git a/tsconfig.json b/tsconfig.json index 2bd383f..cb127d1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,22 @@ { "compilerOptions": { "sourceMap": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "noImplicitAny": true, "module": "commonjs", "jsx": "react", "jsxFactory": "h", - "target": "es5" + "target": "es5", + "lib": [ + "es5", + "dom", + "es2015" + ] }, "include": [ "src/*.ts", - "src/*.tsx" + "src/*.tsx", + "mixer.d.ts" ] -} \ No newline at end of file +} From 4b4fb930fa6b98d876fdceedc7219e78ddb05858 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 11 Aug 2017 18:27:21 -0700 Subject: [PATCH 2/3] external mixer stdlib --- webpack.config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webpack.config.js b/webpack.config.js index 62568da..7a34e20 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,7 +21,10 @@ module.exports = { } ] }, + externals: { + mixer: 'mixer', + }, plugins: [ new CheckerPlugin() ] -}; \ No newline at end of file +}; From 8fb132643e8e24131d7f9eadb8face48b3187572 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Sun, 13 Aug 2017 00:18:06 -0700 Subject: [PATCH 3/3] Address PR comments, first Mixer webpack plugin build --- build/.gitignore | 2 ++ mixer.d.ts | 19 ++++++++++++-- package.json | 6 +++++ src/alchemy/Control.ts | 31 ++++++++++++++++++++--- src/alchemy/Preact.tsx | 53 +++++++++++++++++++++++++++++++++------ {build => src}/index.html | 0 src/index.tsx | 7 +++++- webpack.config.js | 5 +++- 8 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 build/.gitignore rename {build => src}/index.html (100%) diff --git a/build/.gitignore b/build/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/build/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/mixer.d.ts b/mixer.d.ts index f1cf5d9..eaf2752 100644 --- a/mixer.d.ts +++ b/mixer.d.ts @@ -137,7 +137,7 @@ declare module 'mixer' { /** * Attaches a handler function that will be triggered when the call comes in. */ - export class Socket extends EventEmitter { + export interface Socket extends EventEmitter { on(event: 'onParticipantJoin', handler: (ev: IParticipantUpdate) => void): this; on(event: 'onParticipantUpdate', handler: (ev: IParticipantUpdate) => void): this; on(event: 'onGroupCreate', handler: (ev: IGroupCreate) => void): this; @@ -201,7 +201,7 @@ declare module 'mixer' { /** * Display modified the display of interactive controls. */ - export class Display { + export interface Display { /** * Hides the controls and displays a loading spinner, optionally * with a custom message. This is useful for transitioning. If called @@ -220,6 +220,21 @@ declare module 'mixer' { moveVideo(options: IVideoPositionOptions): void; } + /** + * Returns the fully qualified URL to a static project asset, from the + * `src/static` folder. + */ + export function asset(...path: string[]): string; + + /** + * IPackageConfig describes the configuration you write in the "interactive" + * section of your package.json. It's injected automatically when your + * controls boot. + */ + export interface IPackageConfig { + + } + export const socket: Socket; export const display: Display; } diff --git a/package.json b/package.json index 2f4a968..6b1f297 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,15 @@ "test": "echo \"Error: no test specified\" && exit 1", "start": "webpack-dev-server --content-base build/" }, + "interactive": { + "display": { + "mode": "legacy-grid" + } + }, "author": "Connor Peet ", "license": "MIT", "dependencies": { + "decko": "^1.2.0", "eventemitter3": "^2.0.3", "preact": "^8.2.1" }, diff --git a/src/alchemy/Control.ts b/src/alchemy/Control.ts index d51d0b0..97a0fe8 100644 --- a/src/alchemy/Control.ts +++ b/src/alchemy/Control.ts @@ -1,5 +1,30 @@ import { Component } from 'preact'; +/** + * Dimensions exist on every Interactive control and define its display. + */ +export interface IDimensions { + /** + * x position (percent from 0 to 100) + */ + x: number; + + /** + * y position (percent from 0 to 100) + */ + y: number; + + /** + * control width (percent from 0 to 100) + */ + width: number; + + /** + * control height (percent from 0 to 100) + */ + height: number; +} + /** * IControlOptions are passed to the @Control decorator to describe how * the control is rendered. @@ -34,9 +59,9 @@ export interface ISceneOptions { /** * When specified, registers this class to handle a specific scene ID. - * For instance, if you wanted the scenes `lobby` and `arena` to have - * two different scenes, you could do that with something like the - * following: + * For instance, if you wanted the scene IOD `lobby` and `arena` to be + * implemented with two different classes, you could do that with + * something like the following: * * ``` * @Scene({ id: 'lobby' }) diff --git a/src/alchemy/Preact.tsx b/src/alchemy/Preact.tsx index 16c64ed..d96a67c 100644 --- a/src/alchemy/Preact.tsx +++ b/src/alchemy/Preact.tsx @@ -9,13 +9,27 @@ import * as mixer from 'mixer'; @Scene({ default: true }) export class PreactScene extends Component<{ scene: MScene }, T & mixer.IScene> { protected readonly scene: MScene; + private sceneUpdateListener = (ev: mixer.IScene) => { + this.setState(Object.assign({}, this.state, ev)); + }; constructor(props: { scene: MScene }, attributes: T) { super(props, attributes); this.scene = props.scene; - this.scene.on('update', ev => { - this.setState(Object.assign({}, this.state, ev)); - }); + } + + /** + * @override + */ + public componentWillMount() { + this.scene.on('update', this.sceneUpdateListener); + } + + /** + * @override + */ + public componentWillUnmount() { + this.scene.removeListener('update', this.sceneUpdateListener); } public render() { @@ -79,14 +93,37 @@ export class PreactStage extends Component { */ export abstract class PreactControl extends Component<{ control: MControl }, T & mixer.IControl> { protected readonly control: MControl; + private controlUpdateListener = (ev: mixer.IScene) => { + this.setState(Object.assign({}, this.state, ev)); + }; constructor(props: { control: MControl }, attributes: T) { - super(props, attributes); + super(); this.control = props.control; - this.control.on('update', ev => { - this.setState(Object.assign({}, this.state, ev)); - }); + } - Object.defineProperty + /** + * @override + */ + public componentWillMount() { + this.control.on('update', this.controlUpdateListener); + } + + /** + * @override + */ + public componentWillUnmount() { + this.control.removeListener('update', this.controlUpdateListener); } } + +/** + * Helper to conditionally join classes together. For example, passing in + * `{ pressed: true, disabled: false focused: true }` + * would yield `pressed focused`. + */ +export function classes(cls: { [key: string]: boolean }): string { + return Object.keys(cls) + .filter(key => cls[key]) + .join(' '); +} \ No newline at end of file diff --git a/build/index.html b/src/index.html similarity index 100% rename from build/index.html rename to src/index.html diff --git a/src/index.tsx b/src/index.tsx index e10f93a..4e374d5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,9 @@ -import { Control, Input, PreactControl, PreactScene, PreactStage, Scene } from './alchemy'; +import { bind } from 'decko'; import { h, render } from 'preact'; +import { Control, Input, Scene } from './alchemy'; +import { PreactControl, PreactScene, PreactStage, classes } from './alchemy/Preact'; + @Control({ kind: 'button' }) export class Button extends PreactControl<{ pressed: boolean }> { @Input() @@ -10,10 +13,12 @@ export class Button extends PreactControl<{ pressed: boolean }> { return } + @bind protected mousedown() { this.control.giveInput({ event: 'mousedown' }) } + @bind protected mouseup() { this.control.giveInput({ event: 'mousedown' }) } diff --git a/webpack.config.js b/webpack.config.js index 7a34e20..8fc8932 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,7 @@ const path = require('path'); + const { CheckerPlugin } = require('awesome-typescript-loader'); +const { MixerPlugin } = require('miix'); module.exports = { devtool: 'source-map', @@ -25,6 +27,7 @@ module.exports = { mixer: 'mixer', }, plugins: [ - new CheckerPlugin() + new CheckerPlugin(), + new MixerPlugin({ homepage: 'src/index.html' }), ] };