diff --git a/.storybook/main.ts b/.storybook/main.ts index 097de03c004da..d9f3bc19feb4c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -13,7 +13,7 @@ const config: StorybookConfig = { 'storybook-addon-pseudo-states', ], - staticDirs: ['public'], + staticDirs: ['public', { from: '../frontend/public', to: '/static' }], webpackFinal: (config) => { const mainConfig = createEntry('main') diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png index 6b9f594ab179a..3aa44a392089a 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png index 5110ee4a39e22..5eca09f381ea2 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png differ diff --git a/frontend/public/hedgehog/sprites/heatmaps.png b/frontend/public/hedgehog/sprites/heatmaps.png deleted file mode 100644 index e03c51fd2d113..0000000000000 Binary files a/frontend/public/hedgehog/sprites/heatmaps.png and /dev/null differ diff --git a/frontend/public/hedgehog/sprites/overlays/fire.png b/frontend/public/hedgehog/sprites/overlays/fire.png new file mode 100644 index 0000000000000..e0a6139aedcb3 Binary files /dev/null and b/frontend/public/hedgehog/sprites/overlays/fire.png differ diff --git a/frontend/public/hedgehog/sprites/action.png b/frontend/public/hedgehog/sprites/skins/default/action.png similarity index 100% rename from frontend/public/hedgehog/sprites/action.png rename to frontend/public/hedgehog/sprites/skins/default/action.png diff --git a/frontend/public/hedgehog/sprites/fall.png b/frontend/public/hedgehog/sprites/skins/default/fall.png similarity index 100% rename from frontend/public/hedgehog/sprites/fall.png rename to frontend/public/hedgehog/sprites/skins/default/fall.png diff --git a/frontend/public/hedgehog/sprites/flag.png b/frontend/public/hedgehog/sprites/skins/default/flag.png similarity index 100% rename from frontend/public/hedgehog/sprites/flag.png rename to frontend/public/hedgehog/sprites/skins/default/flag.png diff --git a/frontend/public/hedgehog/sprites/inspect.png b/frontend/public/hedgehog/sprites/skins/default/inspect.png similarity index 100% rename from frontend/public/hedgehog/sprites/inspect.png rename to frontend/public/hedgehog/sprites/skins/default/inspect.png diff --git a/frontend/public/hedgehog/sprites/jump.png b/frontend/public/hedgehog/sprites/skins/default/jump.png similarity index 100% rename from frontend/public/hedgehog/sprites/jump.png rename to frontend/public/hedgehog/sprites/skins/default/jump.png diff --git a/frontend/public/hedgehog/sprites/phone.png b/frontend/public/hedgehog/sprites/skins/default/phone.png similarity index 100% rename from frontend/public/hedgehog/sprites/phone.png rename to frontend/public/hedgehog/sprites/skins/default/phone.png diff --git a/frontend/public/hedgehog/sprites/sign.png b/frontend/public/hedgehog/sprites/skins/default/sign.png similarity index 100% rename from frontend/public/hedgehog/sprites/sign.png rename to frontend/public/hedgehog/sprites/skins/default/sign.png diff --git a/frontend/public/hedgehog/sprites/walk.png b/frontend/public/hedgehog/sprites/skins/default/walk.png similarity index 100% rename from frontend/public/hedgehog/sprites/walk.png rename to frontend/public/hedgehog/sprites/skins/default/walk.png diff --git a/frontend/public/hedgehog/sprites/wave.png b/frontend/public/hedgehog/sprites/skins/default/wave.png similarity index 100% rename from frontend/public/hedgehog/sprites/wave.png rename to frontend/public/hedgehog/sprites/skins/default/wave.png diff --git a/frontend/public/hedgehog/sprites/skins/spiderhog/fall.png b/frontend/public/hedgehog/sprites/skins/spiderhog/fall.png new file mode 100644 index 0000000000000..575a45dd4a47f Binary files /dev/null and b/frontend/public/hedgehog/sprites/skins/spiderhog/fall.png differ diff --git a/frontend/public/hedgehog/sprites/skins/spiderhog/jump.png b/frontend/public/hedgehog/sprites/skins/spiderhog/jump.png new file mode 100644 index 0000000000000..410a4eec6ae88 Binary files /dev/null and b/frontend/public/hedgehog/sprites/skins/spiderhog/jump.png differ diff --git a/frontend/public/hedgehog/sprites/skins/spiderhog/walk.png b/frontend/public/hedgehog/sprites/skins/spiderhog/walk.png new file mode 100644 index 0000000000000..f456d94a48b94 Binary files /dev/null and b/frontend/public/hedgehog/sprites/skins/spiderhog/walk.png differ diff --git a/frontend/public/hedgehog/sprites/skins/spiderhog/wave.png b/frontend/public/hedgehog/sprites/skins/spiderhog/wave.png new file mode 100644 index 0000000000000..116ce531cbd9c Binary files /dev/null and b/frontend/public/hedgehog/sprites/skins/spiderhog/wave.png differ diff --git a/frontend/public/hedgehog/sprites/spin.png b/frontend/public/hedgehog/sprites/spin.png deleted file mode 100644 index 85ef2ef67ab59..0000000000000 Binary files a/frontend/public/hedgehog/sprites/spin.png and /dev/null differ diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.scss b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.scss index 29a642d2f7c8a..46c8a466a29c6 100644 --- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.scss +++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.scss @@ -1,5 +1,4 @@ .HedgehogBuddy { - position: fixed; z-index: var(--z-hedgehog-buddy); margin: 0; cursor: pointer; diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx index 33c2f5b9c51b0..0b8ff1faedf49 100644 --- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx +++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddy.tsx @@ -1,6 +1,6 @@ import './HedgehogBuddy.scss' -import { ProfilePicture } from '@posthog/lemon-ui' +import { lemonToast, ProfilePicture } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' import { router } from 'kea-router' @@ -18,13 +18,18 @@ import { COLOR_TO_FILTER_MAP, hedgehogBuddyLogic } from './hedgehogBuddyLogic' import { HedgehogOptions } from './HedgehogOptions' import { AccessoryInfo, - baseSpriteAccessoriesPath, - baseSpritePath, + AnimationName, + OverlayAnimationName, + overlayAnimations, SHADOW_HEIGHT, + skins, SPRITE_SHEET_WIDTH, SPRITE_SIZE, + spriteAccessoryUrl, + SpriteInfo, + spriteOverlayUrl, + spriteUrl, standardAccessories, - standardAnimations, } from './sprites/sprites' export const X_FRAMES = SPRITE_SHEET_WIDTH / SPRITE_SIZE @@ -32,17 +37,14 @@ export const FPS = 24 const GRAVITY_PIXELS = 10 const MAX_JUMP_COUNT = 2 -const randomChoiceList: string[] = Object.keys(standardAnimations).reduce((acc: string[], key: string) => { - return [...acc, ...range(standardAnimations[key].randomChance || 0).map(() => key)] -}, []) - export type HedgehogBuddyProps = { onActorLoaded?: (actor: HedgehogActor) => void - onClose?: () => void - onClick?: () => void + onClose?: (actor: HedgehogActor) => void + onClick?: (actor: HedgehogActor) => void onPositionChange?: (actor: HedgehogActor) => void hedgehogConfig?: HedgehogConfig tooltip?: JSX.Element + static?: boolean } type Box = { @@ -73,46 +75,62 @@ const elementToBox = (element: Element): Box => { } } +type AnimationState = { + name: string + frame: number + iterations: number | null + spriteInfo: SpriteInfo + onComplete?: () => boolean | void +} + export class HedgehogActor { element?: HTMLDivElement | null - animations = standardAnimations direction: 'left' | 'right' = 'right' - startX = Math.min(Math.max(0, Math.floor(Math.random() * window.innerWidth)), window.innerWidth - SPRITE_SIZE) - startY = Math.min(Math.max(0, Math.floor(Math.random() * window.innerHeight)), window.innerHeight - SPRITE_SIZE) + startX = 0 + startY = 0 x = this.startX y = this.startY + followMouse = false + lastKnownMousePosition: [number, number] | null = null isDragging = false isControlledByUser = false yVelocity = -30 // Appears as if jumping out of thin air xVelocity = 0 ground: Element | null = null jumpCount = 0 - animationName: string = 'fall' - animation = this.animations[this.animationName] - animationFrame = 0 - animationIterations: number | null = null - animationCompletionHandler?: () => boolean | void + mainAnimation: AnimationState | null = null + overlayAnimation: AnimationState | null = null + gravity = GRAVITY_PIXELS ignoreGroundAboveY?: number showTooltip = false lastScreenPosition = [window.screenX, window.screenY + window.innerHeight] + static = false // properties synced with the logic hedgehogConfig: Partial = {} tooltip?: JSX.Element constructor() { + this.log('Created new HedgehogActor') + + this.startX = Math.min( + Math.max(0, Math.floor(Math.random() * window.innerWidth)), + window.innerWidth - SPRITE_SIZE + ) + this.startY = Math.min( + Math.max(0, Math.floor(Math.random() * window.innerHeight)), + window.innerHeight - SPRITE_SIZE + ) this.setAnimation('fall') } - private accessories(): AccessoryInfo[] { - return this.hedgehogConfig.accessories?.map((acc) => standardAccessories[acc]) ?? [] + animations(): { [key: string]: SpriteInfo } { + const animations = skins[this.hedgehogConfig.skin || 'default'] + return animations } - private getAnimationOptions(): string[] { - if (!this.hedgehogConfig.walking_enabled) { - return randomChoiceList.filter((x) => x !== 'walk') - } - return randomChoiceList + private accessories(): AccessoryInfo[] { + return this.hedgehogConfig.accessories?.map((acc) => standardAccessories[acc]) ?? [] } private log(message: string, ...args: any[]): void { @@ -124,21 +142,68 @@ export class HedgehogActor { setOnFire(times = 3): void { this.log('setting on fire, iterations remaining:', times) - this.setAnimation('heatmaps', { + this.setOverlayAnimation('fire', { onComplete: () => { if (times == 1) { - return + this.setOverlayAnimation(null) + } else { + this.setOnFire(times - 1) } - this.setOnFire(times - 1) - return true }, }) + + this.setAnimation('stop', {}) this.direction = sampleOne(['left', 'right']) this.xVelocity = this.direction === 'left' ? -5 : 5 this.jump() } setupKeyboardListeners(): () => void { + const lastKeys: string[] = [] + + const secretMap: { + keys: string[] + action: () => void + }[] = [ + { + keys: ['f', 'f', 'f'], + action: () => this.setOnFire(), + }, + { + keys: ['f', 'i', 'r', 'e'], + action: () => this.setOnFire(), + }, + { + keys: ['s', 'p', 'i', 'd', 'e', 'r', 'h', 'o', 'g'], + action: () => { + this.hedgehogConfig.skin = 'spiderhog' + }, + }, + { + keys: [ + 'arrowup', + 'arrowup', + 'arrowdown', + 'arrowdown', + 'arrowleft', + 'arrowright', + 'arrowleft', + 'arrowright', + 'b', + 'a', + ], + action: () => { + this.setOnFire() + this.gravity = -2 + + lemonToast.info('I must leave. My people need me!') + setTimeout(() => { + this.gravity = GRAVITY_PIXELS + }, 2000) + }, + }, + ] + const keyDownListener = (e: KeyboardEvent): void => { if (shouldIgnoreInput(e) || !this.hedgehogConfig.controls_enabled) { return @@ -146,13 +211,25 @@ export class HedgehogActor { const key = e.key.toLowerCase() + lastKeys.push(key) + if (lastKeys.length > 20) { + lastKeys.shift() + } + if ([' ', 'w', 'arrowup'].includes(key)) { this.jump() } + secretMap.forEach((secret) => { + if (lastKeys.slice(-secret.keys.length).join('') === secret.keys.join('')) { + secret.action() + lastKeys.splice(-secret.keys.length) + } + }) + if (['arrowdown', 's'].includes(key)) { if (this.ground === document.body) { - if (this.animationName !== 'wave') { + if (this.mainAnimation?.name !== 'wave') { this.setAnimation('wave') } } else if (this.ground) { @@ -165,7 +242,7 @@ export class HedgehogActor { if (['arrowleft', 'a', 'arrowright', 'd'].includes(key)) { this.isControlledByUser = true - if (this.animationName !== 'walk') { + if (this.mainAnimation?.name !== 'walk') { this.setAnimation('walk') } @@ -178,8 +255,6 @@ export class HedgehogActor { // Moonwalking is hard so he moves slightly slower of course this.xVelocity *= 0.8 } - - this.animationIterations = null } } @@ -191,14 +266,51 @@ export class HedgehogActor { const key = e.key.toLowerCase() if (['arrowleft', 'a', 'arrowright', 'd'].includes(key)) { - this.setAnimation('stop') - this.animationIterations = FPS * 2 // Wait 2 seconds before doing something else + this.setAnimation('stop', { + iterations: FPS * 2, + }) this.isControlledByUser = false } } + const onMouseDown = (e: MouseEvent): void => { + if (!this.hedgehogConfig.controls_enabled || this.hedgehogConfig.skin !== 'spiderhog') { + return + } + + // Whilst the mouse is down we will move the hedgehog towards it + // First check that we haven't clicked the hedgehog + const elementBounds = this.element?.getBoundingClientRect() + if ( + elementBounds && + e.clientX >= elementBounds.left && + e.clientX <= elementBounds.right && + e.clientY >= elementBounds.top && + e.clientY <= elementBounds.bottom + ) { + return + } + + this.setAnimation('fall') + this.followMouse = true + this.lastKnownMousePosition = [e.clientX, e.clientY] + + const onMouseMove = (e: MouseEvent): void => { + this.lastKnownMousePosition = [e.clientX, e.clientY] + } + + const onMouseUp = (): void => { + this.followMouse = false + window.removeEventListener('mousemove', onMouseMove) + } + + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + } + window.addEventListener('keydown', keyDownListener) window.addEventListener('keyup', keyUpListener) + window.addEventListener('mousedown', onMouseDown) return () => { window.removeEventListener('keydown', keyDownListener) @@ -206,28 +318,27 @@ export class HedgehogActor { } } - setAnimation( - animationName: string, - options?: { - onComplete: () => boolean | void - } - ): void { - this.animationName = animationName - this.animation = this.animations[animationName] - this.animationFrame = 0 - this.animationCompletionHandler = () => { - this.animationCompletionHandler = undefined - - return options?.onComplete() - } - if (this.animationName !== 'stop') { - this.direction = this.animation.forceDirection || sampleOne(['left', 'right']) + setAnimation(animationName: AnimationName, options?: Partial): void { + const availableAnimations = this.animations() + animationName = availableAnimations[animationName] ? animationName : 'stop' + const spriteInfo = availableAnimations[animationName] + + this.mainAnimation = { + name: animationName, + frame: 0, + iterations: spriteInfo.maxIteration ?? null, + spriteInfo, + onComplete: options?.onComplete, } // Set a random number of iterations or infinite for certain situations - this.animationIterations = this.animation.maxIteration - ? Math.max(1, Math.floor(Math.random() * this.animation.maxIteration)) - : null + this.mainAnimation.iterations = + options?.iterations ?? + (spriteInfo.maxIteration ? Math.max(1, Math.floor(Math.random() * spriteInfo.maxIteration)) : null) + + if (this.mainAnimation.name !== 'stop') { + this.direction = this.mainAnimation.spriteInfo.forceDirection || sampleOne(['left', 'right']) + } if (animationName === 'walk') { this.xVelocity = this.direction === 'left' ? -1 : 1 @@ -236,19 +347,52 @@ export class HedgehogActor { } if ((window as any)._posthogDebugHedgehog) { - const duration = this.animationIterations - ? this.animationIterations * this.animation.frames * (1000 / FPS) - : '∞' + const duration = + this.mainAnimation.iterations !== null + ? this.mainAnimation.iterations * spriteInfo.frames * (1000 / FPS) + : '∞' - this.log(`Will '${this.animationName}' for ${duration}ms`) + this.log(`Will '${this.mainAnimation.name}' for ${duration}ms`) + } + } + + setOverlayAnimation( + animationName: OverlayAnimationName | null, + options?: { + onComplete: () => boolean | void + } + ): void { + if (!animationName) { + this.overlayAnimation = null + return + } + const spriteInfo = overlayAnimations[animationName] + if (!spriteInfo) { + this.log(`Overlay animation '${animationName}' not found`) + return + } + + this.overlayAnimation = { + name: animationName, + frame: 0, + iterations: 1, + spriteInfo, + onComplete: options?.onComplete ?? (() => this.setOverlayAnimation(null)), } } setRandomAnimation(): void { - if (this.animationName !== 'stop') { + if (this.mainAnimation?.name !== 'stop') { this.setAnimation('stop') } else { - this.setAnimation(sampleOne(this.getAnimationOptions())) + let randomChoiceList = Object.keys(this.animations()).reduce((acc, key) => { + return [...acc, ...range(this.animations()[key].randomChance || 0).map(() => key)] as AnimationName[] + }, [] as AnimationName[]) + + randomChoiceList = this.hedgehogConfig.walking_enabled + ? randomChoiceList + : randomChoiceList.filter((x) => x !== 'walk') + this.setAnimation(sampleOne(randomChoiceList)) } } @@ -258,7 +402,7 @@ export class HedgehogActor { } this.ground = null this.jumpCount += 1 - this.yVelocity = GRAVITY_PIXELS * 5 + this.yVelocity = this.gravity * 5 } update(): void { @@ -283,11 +427,11 @@ export class HedgehogActor { if (screenMoveY < 0) { // If the ground has moved up relative to the hedgehog we need to make him jump - this.yVelocity = Math.max(this.yVelocity + screenMoveY * 10, -GRAVITY_PIXELS * 20) + this.yVelocity = Math.max(this.yVelocity + screenMoveY * 10, -this.gravity * 20) } if (screenMoveX !== 0) { - if (this.animationName !== 'stop') { + if (this.mainAnimation?.name !== 'stop') { this.setAnimation('stop') } // Somewhat random numbers here to find what felt fun @@ -297,30 +441,53 @@ export class HedgehogActor { this.applyVelocity() - // Ensure we are falling or not - if (this.animationName === 'fall' && !this.isFalling()) { - this.setAnimation('stop') - } + if (this.mainAnimation) { + // Ensure we are falling or not + if (this.mainAnimation.name === 'fall' && !this.isFalling()) { + this.setAnimation('stop') + } - this.animationFrame++ + this.mainAnimation.frame++ - if (this.animationFrame >= this.animation.frames) { - // End of the animation - if (this.animationIterations !== null) { - this.animationIterations -= 1 - } + if (this.mainAnimation.frame >= this.mainAnimation.spriteInfo.frames) { + this.mainAnimation.frame = 0 + // End of the animation + if (this.mainAnimation.iterations !== null) { + this.mainAnimation.iterations -= 1 + } + + if (this.mainAnimation.iterations === 0) { + this.mainAnimation.iterations = null + // End of the animation, set the next one - if (this.animationIterations === 0) { - this.animationIterations = null - // End of the animation, set the next one + const preventNextAnimation = this.mainAnimation.onComplete?.() - const preventNextAnimation = this.animationCompletionHandler?.() - if (!preventNextAnimation) { - this.setRandomAnimation() + if (!preventNextAnimation) { + if (this.static) { + this.setAnimation('stop') + } else { + this.setRandomAnimation() + } + } } } + } - this.animationFrame = 0 + if (this.overlayAnimation) { + this.overlayAnimation.frame++ + + if (this.overlayAnimation.frame >= this.overlayAnimation.spriteInfo.frames) { + this.overlayAnimation.frame = 0 + // End of the animation + if (this.overlayAnimation.iterations !== null) { + this.overlayAnimation.iterations -= 1 + } + + if (this.overlayAnimation.iterations === 0) { + this.overlayAnimation.iterations = null + this.overlayAnimation.onComplete?.() + } + } } if (this.isDragging) { @@ -352,11 +519,39 @@ export class HedgehogActor { return } + if (this.followMouse) { + this.ground = null + const [clientX, clientY] = this.lastKnownMousePosition ?? [0, 0] + + const xDiff = clientX - this.x + const yDiff = window.innerHeight - clientY - this.y + + const distance = Math.sqrt(xDiff ** 2 + yDiff ** 2) + const speed = 3 + const ratio = speed / distance + + if (yDiff < 0) { + this.yVelocity -= this.gravity + } + + this.yVelocity += yDiff * ratio + this.xVelocity += xDiff * ratio + this.y = this.y + this.yVelocity + if (this.y < 0) { + this.y = 0 + this.yVelocity = -this.yVelocity * 0.4 + } + this.x = this.x + this.xVelocity + this.direction = this.xVelocity > 0 ? 'right' : 'left' + + return + } + this.ground = this.findGround() - this.yVelocity -= GRAVITY_PIXELS + this.yVelocity -= this.gravity // We decelerate the x velocity if the hedgehog is stopped - if (['stop'].includes(this.animationName) && !this.isControlledByUser) { + if (!this.isControlledByUser && this.mainAnimation?.name !== 'walk' && this.onGround()) { this.xVelocity = this.xVelocity * 0.6 } @@ -445,6 +640,9 @@ export class HedgehogActor { } private onGround(): boolean { + if (this.static) { + return true + } if (this.ground) { const groundLevel = elementToBox(this.ground).y + elementToBox(this.ground).height return this.y <= groundLevel @@ -457,15 +655,45 @@ export class HedgehogActor { return !this.onGround() && Math.abs(this.yVelocity) > 1 } + renderRope(): JSX.Element | null { + if (!this.lastKnownMousePosition) { + return null + } + + // We position the rope to roughly where the hand should be + const x = this.x + SPRITE_SIZE / 2 + const y = this.y + SPRITE_SIZE / 2 + const mouseX = this.lastKnownMousePosition[0] + // Y coords are inverted + const mouseY = window.innerHeight - this.lastKnownMousePosition[1] + + return ( +
+ ) + } + render({ onClick, ref }: { onClick: () => void; ref: ForwardedRef }): JSX.Element { - const accessoryPosition = this.animation.accessoryPositions?.[this.animationFrame] + const accessoryPosition = this.mainAnimation?.spriteInfo.accessoryPositions?.[this.mainAnimation.frame] const preloadContent = - Object.values(this.animations) - .map((x) => `url(${baseSpritePath()}/${x.img}.png)`) + Object.values(this.animations()) + .map((x) => `url(${spriteUrl(this.hedgehogConfig.skin ?? 'default', x.img)})`) .join(' ') + ' ' + this.accessories() - .map((accessory) => `url(${baseSpriteAccessoriesPath}/${accessory.img}.png)`) + .map((accessory) => `url(${spriteAccessoryUrl(accessory.img)})`) .join(' ') const imageFilter = this.hedgehogConfig.color ? COLOR_TO_FILTER_MAP[this.hedgehogConfig.color] : undefined @@ -549,16 +777,17 @@ export class HedgehogActor { }} className="HedgehogBuddy" data-content={preloadContent} - onTouchStart={() => onTouchOrMouseStart()} - onMouseDown={() => onTouchOrMouseStart()} + onTouchStart={this.static ? undefined : () => onTouchOrMouseStart()} + onMouseDown={this.static ? undefined : () => onTouchOrMouseStart()} onMouseOver={() => (this.showTooltip = true)} onMouseOut={() => (this.showTooltip = false)} + onClick={this.static ? onClick : undefined} // eslint-disable-next-line react/forbid-dom-props style={{ - position: 'fixed', - left: this.x, - bottom: this.y - SHADOW_HEIGHT * 0.5, - transition: !this.isDragging ? `all ${1000 / FPS}ms` : undefined, + position: this.static ? 'relative' : 'fixed', + left: this.static ? undefined : this.x, + bottom: this.static ? undefined : this.y - SHADOW_HEIGHT * 0.5, + transition: !(this.isDragging || this.followMouse) ? `all ${1000 / FPS}ms` : undefined, cursor: 'pointer', margin: 0, }} @@ -585,19 +814,27 @@ export class HedgehogActor { transform: `scaleX(${this.direction === 'right' ? 1 : -1})`, }} > -
+ {this.mainAnimation ? ( +
+ ) : null} + {this.accessories().map((accessory, index) => (
))} + {this.overlayAnimation ? ( +
+ ) : null}
+ {this.renderRope()} + {(window as any)._posthogDebugHedgehog && ( <> {[this.element && elementToBox(this.element), this.ground && elementToBox(this.ground)].map( @@ -651,7 +908,7 @@ export class HedgehogActor { } export const HedgehogBuddy = React.forwardRef(function HedgehogBuddy( - { onActorLoaded, onClick: _onClick, onPositionChange, hedgehogConfig, tooltip }, + { onActorLoaded, onClick: _onClick, onPositionChange, hedgehogConfig, tooltip, static: staticMode }, ref ): JSX.Element { const actorRef = useRef() @@ -682,6 +939,10 @@ export const HedgehogBuddy = React.forwardRef { + actor.static = staticMode ?? false + }, [staticMode]) + useEffect(() => { let timer: any = null @@ -712,7 +973,7 @@ export const HedgehogBuddy = React.forwardRef { - !actor.isDragging && _onClick?.() + !actor.isDragging && _onClick?.(actor) } return actor.render({ onClick, ref }) @@ -734,16 +995,19 @@ export function MyHedgehogBuddy({ const [popoverVisible, setPopoverVisible] = useState(false) - const onClick = (): void => { + const onClick = (actor: HedgehogActor): void => { setPopoverVisible(!popoverVisible) - _onClick?.() + _onClick?.(actor) } const disappear = (): void => { setPopoverVisible(false) - actor?.setAnimation('wave') - setTimeout(() => onClose?.(), (actor!.animations.wave.frames * 1000) / FPS) + actor?.setAnimation('wave', { + onComplete() { + onClose?.(actor) + return true + }, + }) } - return ( setPopoverVisible(false)} @@ -797,7 +1061,11 @@ export function MemberHedgehogBuddy({ member }: { member: OrganizationMemberType const memberHedgehogConfig: HedgehogConfig = useMemo( () => ({ ...hedgehogConfig, + // Reset some params to default + skin: 'default', + // Then apply the user's config ...member.user.hedgehog_config, + // Finally some settings are forced controls_enabled: false, }), [hedgehogConfig, member.user.hedgehog_config] diff --git a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddyRender.tsx b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddyRender.tsx index fc12a1396a342..8c766f9bda66e 100644 --- a/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddyRender.tsx +++ b/frontend/src/lib/components/HedgehogBuddy/HedgehogBuddyRender.tsx @@ -1,55 +1,22 @@ -import { useEffect, useRef, useState } from 'react' - import { HedgehogConfig } from '~/types' -import { FPS, X_FRAMES } from './HedgehogBuddy' import { COLOR_TO_FILTER_MAP } from './hedgehogBuddyLogic' -import { - baseSpriteAccessoriesPath, - baseSpritePath, - SPRITE_SIZE, - standardAccessories, - standardAnimations, -} from './sprites/sprites' +import { spriteAccessoryUrl, spriteUrl, standardAccessories } from './sprites/sprites' -export type HedgehogBuddyStaticProps = Partial & { size?: number | string; waveOnAppearance?: boolean } +export type HedgehogBuddyStaticProps = Partial & { size?: number | string } // Takes a range of options and renders a static hedgehog export function HedgehogBuddyStatic({ accessories, color, size, - waveOnAppearance, + skin = 'default', }: HedgehogBuddyStaticProps): JSX.Element { const imgSize = size ?? 60 const accessoryInfos = accessories?.map((x) => standardAccessories[x]) const filter = color ? COLOR_TO_FILTER_MAP[color] : null - const [animationIteration, setAnimationIteration] = useState(waveOnAppearance ? 1 : 0) - const [_, setTimerLoop] = useState(0) - const animationFrameRef = useRef(0) - - useEffect(() => { - if (animationIteration) { - setTimerLoop(0) - let timer: any = null - const loop = (): void => { - if (animationFrameRef.current < standardAnimations.wave.frames) { - animationFrameRef.current++ - timer = setTimeout(loop, 1000 / FPS) - } else { - animationFrameRef.current = 0 - } - setTimerLoop((x) => x + 1) - } - loop() - return () => { - clearTimeout(timer) - } - } - }, [animationIteration]) - return (
setAnimationIteration((x) => x + 1) : undefined} >
{accessoryInfos?.map((accessory, index) => (

Hi, I'm Max!

- Don't mind me. I'm just here to keep you company. -
- You can move me around by clicking and dragging or control me with WASD / arrow keys. + {hedgehogConfig.skin === 'spiderhog' ? ( + <> + Well, it’s not every day you meet a hedgehog with spider powers. Yep, that's me - + SpiderHog. I wasn’t always this way. Just your average, speedy little guy until a + radioactive spider bit me. With great power comes great responsibility, so buckle up, + because this hedgehog’s got a whole data warehouse to protect... +
+ You can move me around by clicking and dragging or control me with WASD / arrow keys and + I'll use your mouse as a web slinging target. + + ) : ( + <> + Don't mind me. I'm just here to keep you company. +
+ You can move me around by clicking and dragging or control me with WASD / arrow keys. + + )}

@@ -138,13 +155,33 @@ function HedgehogAccessories(): JSX.Element { function HedgehogColor(): JSX.Element { const { hedgehogConfig } = useValues(hedgehogBuddyLogic) const { patchHedgehogConfig } = useActions(hedgehogBuddyLogic) + const skinSpiderHogEnabled = !!useFeatureFlag('HEDGEHOG_SKIN_SPIDERHOG') + + const skins: HedgehogSkin[] = skinSpiderHogEnabled ? ['default', 'spiderhog'] : ['default'] return ( <> -

Colors

+

Skins and colors

- {[null, ...Object.keys(COLOR_TO_FILTER_MAP)].map((option) => ( + {skins.map((option) => ( + patchHedgehogConfig({ skin: option as any, color: null })} + noPadding + tooltip={<>{capitalizeFirstLetter(option ?? 'default')}} + > + + + ))} + {[...Object.keys(COLOR_TO_FILTER_MAP)].map((option) => ( patchHedgehogConfig({ color: option as any })} + onClick={() => patchHedgehogConfig({ color: option as any, skin: 'default' })} noPadding tooltip={<>{capitalizeFirstLetter(option ?? 'default')}} > diff --git a/frontend/src/lib/components/HedgehogBuddy/sprites/sprites.tsx b/frontend/src/lib/components/HedgehogBuddy/sprites/sprites.tsx index 273b97a59ead5..46c30dcbcd866 100644 --- a/frontend/src/lib/components/HedgehogBuddy/sprites/sprites.tsx +++ b/frontend/src/lib/components/HedgehogBuddy/sprites/sprites.tsx @@ -1,10 +1,13 @@ +import { inStorybook } from 'lib/utils' + import { toolbarConfigLogic } from '~/toolbar/toolbarConfigLogic' +import { HedgehogSkin } from '~/types' export const SPRITE_SIZE = 80 export const SHADOW_HEIGHT = SPRITE_SIZE / 8 export const SPRITE_SHEET_WIDTH = SPRITE_SIZE * 8 -type SpriteInfo = { +export type SpriteInfo = { /** Number of frames in this sprite sheet */ frames: number /** Path to the sprite sheet */ @@ -16,6 +19,8 @@ type SpriteInfo = { /** How likely this animation is to be chosen. Higher numbers are more likely. */ randomChance?: number accessoryPositions?: [number, number][] + filter?: string + style?: React.CSSProperties } export const accessoryGroups = ['headwear', 'eyewear', 'other'] as const @@ -28,11 +33,13 @@ export type AccessoryInfo = { // If loaded via the toolbar the root domain won't be app.posthog.com and so the assets won't load // Simple workaround is we detect if the domain is localhost and if not we just use https://us.posthog.com -export const baseSpritePath = (): string => { +const baseSpritePath = (): string => { let path = `/static/hedgehog/sprites` const toolbarAPIUrl = toolbarConfigLogic.findMounted()?.values.apiURL - if (window.location.hostname !== 'localhost') { + if (inStorybook()) { + // Nothing to do + } else if (window.location.hostname !== 'localhost') { path = `https://us.posthog.com${path}` } else if (toolbarAPIUrl) { path = `${toolbarAPIUrl}${path}` @@ -40,9 +47,24 @@ export const baseSpritePath = (): string => { return path } -export const baseSpriteAccessoriesPath = (): string => `${baseSpritePath()}/accessories` +const baseSpriteAccessoriesPath = (): string => `${baseSpritePath()}/accessories` + +export const spriteUrl = (skin: HedgehogSkin, img: string): string => { + return `${baseSpritePath()}/skins/${skin}/${img}.png` +} -export const standardAnimations: { [key: string]: SpriteInfo } = { +export const spriteOverlayUrl = (img: string): string => { + return `${baseSpritePath()}/overlays/${img}.png` +} + +export const spriteAccessoryUrl = (img: string): string => { + return `${baseSpriteAccessoriesPath()}/${img}.png` +} + +const animationsNames = ['stop', 'fall', 'jump', 'sign', 'walk', 'wave', 'flag', 'inspect', 'phone', 'action'] as const +export type AnimationName = (typeof animationsNames)[number] + +const standardAnimations: Record = { stop: { img: 'wave', frames: 1, @@ -92,12 +114,6 @@ export const standardAnimations: { [key: string]: SpriteInfo } = { maxIteration: 1, randomChance: 2, }, - heatmaps: { - img: 'heatmaps', - frames: 14, - maxIteration: 1, - randomChance: 0, - }, flag: { img: 'flag', frames: 25, @@ -124,6 +140,21 @@ export const standardAnimations: { [key: string]: SpriteInfo } = { }, } +const overlayAnimationsNames = ['fire'] as const + +export type OverlayAnimationName = (typeof overlayAnimationsNames)[number] + +export const overlayAnimations: Record = { + fire: { + img: 'fire', + frames: 14, + maxIteration: 1, + style: { + opacity: 0.75, + }, + }, +} + export const standardAccessories: { [key: string]: AccessoryInfo } = { beret: { img: 'beret', @@ -191,3 +222,14 @@ export const standardAccessories: { [key: string]: AccessoryInfo } = { group: 'other', }, } + +export const skins: Record = { + default: standardAnimations, + spiderhog: { + stop: standardAnimations.stop, + fall: standardAnimations.fall, + jump: standardAnimations.jump, + walk: standardAnimations.walk, + wave: standardAnimations.wave, + }, +} diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index c675af662f4d0..f5e94a2e13af6 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -214,6 +214,7 @@ export const FEATURE_FLAGS = { DATA_MODELING: 'data-modeling', // owner: @EDsCODE #team-data-warehouse WEB_ANALYTICS_CONVERSION_GOALS: 'web-analytics-conversion-goals', // owner: @robbie-c WEB_ANALYTICS_LAST_CLICK: 'web-analytics-last-click', // owner: @robbie-c + HEDGEHOG_SKIN_SPIDERHOG: 'hedgehog-skin-spiderhog', // owner: @benjackwhite } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index b95beb0d7ecfb..984708383ed16 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -1676,6 +1676,10 @@ export function inStorybookTestRunner(): boolean { return navigator.userAgent.includes('StorybookTestRunner') } +export function inStorybook(): boolean { + return '__STORYBOOK_CLIENT_API__' in window +} + /** We issue a cancel request, when the request is aborted or times out (frontend side), since in these cases the backend query might still be running. */ export function shouldCancelQuery(error: any): boolean { return isAbortedRequest(error) || isTimedOutRequest(error) diff --git a/frontend/src/scenes/max/Max.tsx b/frontend/src/scenes/max/Max.tsx index 594ca3344aed3..65909db171a5a 100644 --- a/frontend/src/scenes/max/Max.tsx +++ b/frontend/src/scenes/max/Max.tsx @@ -3,13 +3,13 @@ import './Max.scss' import { LemonButton, LemonInput, Spinner } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' -import { HedgehogBuddyStatic } from 'lib/components/HedgehogBuddy/HedgehogBuddyRender' +import { HedgehogBuddy } from 'lib/components/HedgehogBuddy/HedgehogBuddy' +import { hedgehogBuddyLogic } from 'lib/components/HedgehogBuddy/hedgehogBuddyLogic' import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { uuid } from 'lib/utils' import React, { useState } from 'react' import { SceneExport } from 'scenes/sceneTypes' -import { userLogic } from 'scenes/userLogic' import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter' import { Query } from '~/queries/Query/Query' @@ -41,8 +41,8 @@ function Message({ } export function Max(): JSX.Element | null { - const { user } = useValues(userLogic) const { featureFlags } = useValues(featureFlagLogic) + const { hedgehogConfig } = useValues(hedgehogBuddyLogic) const logic = maxLogic({ sessionId: uuid(), @@ -118,12 +118,16 @@ export function Max(): JSX.Element | null { )}
-
- + actor.setAnimation('wave')} + onActorLoaded={(actor) => actor.setAnimation('wave')} />
([ setVisibleMenu: ({ visibleMenu }) => { if (visibleMenu === 'heatmap') { actions.enableHeatmap() - values.hedgehogActor?.setAnimation('heatmaps') + values.hedgehogActor?.setOnFire(1) } else if (visibleMenu === 'actions') { actions.showButtonActions() values.hedgehogActor?.setAnimation('action') diff --git a/frontend/src/toolbar/hedgehog/HedgehogButton.tsx b/frontend/src/toolbar/hedgehog/HedgehogButton.tsx index d0082012e0367..22403fa431ffb 100644 --- a/frontend/src/toolbar/hedgehog/HedgehogButton.tsx +++ b/frontend/src/toolbar/hedgehog/HedgehogButton.tsx @@ -15,7 +15,7 @@ export function HedgehogButton(): JSX.Element { useEffect(() => { if (heatmapEnabled) { - hedgehogActor?.setAnimation('heatmaps') + hedgehogActor?.setOnFire(1) } }, [heatmapEnabled]) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 56544e25f9fd8..94cedbd1c1566 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -295,9 +295,12 @@ export interface MinimalHedgehogConfig { accessories: string[] } +export type HedgehogSkin = 'default' | 'spiderhog' + export interface HedgehogConfig extends MinimalHedgehogConfig { enabled: boolean color: HedgehogColorOptions | null + skin?: HedgehogSkin accessories: string[] walking_enabled: boolean interactions_enabled: boolean