Bringing the WWW to the AAA

ðŸĨ Sylvain Hamann

ðŸ‘Ļ‍ðŸ’ŧ Web Developer

ðŸ‡Ŧ🇷 ✈ïļ ðŸ‡°ðŸ‡· ✈ïļ ðŸ‡ŊðŸ‡ĩ ✈ïļ ðŸ‡ĻðŸ‡Ķ

ðŸĪ @sylvhama

What's Ubisoft Club? ðŸĪ”

UI for a TV Screen 📚

  • User comes from the game
  • The game communicates via query parameters
  • 1920x1080 viewport
  • Adaptive Web Design
  • Two Web Browsers: Web Dialog and UWP app
  • Visual / Behavior guidelines imposed by Sony or Microsoft

Did you know? X and â—Ŋ behaviors are reversed in Japan.

Process

  1. Dev on Chrome (react / redux devtoools)
  2. Continuous Integration and Deployment via GitLab ðŸĶŠ
  3. Test on a dev kit via a fake game

But not everyone has a dev kit 😟

Storybook + "PC" build

Gamepad Navigation ðŸŽŪ

The user isn't using a keyboard or a mouse!
However the gamepad is firing keyboard events. ðŸĪŠ

Global event listener

onKeyDown = throttle(e => {
const {
focusedNode: { depth, rect },
navigate,
nodes,
showExitModal
} = this.props;
switch (e.keyCode) {
case 27: // PS: â—Ŋ button ; PC: Esc
case 196: // XBOX: B button
this.goBackOrShowExitModal();
break;
case 112: // PS: â–ģ button
case 198: // XBOX: Y button
showExitModal();
break;
case 37: // PS: DPAD and LEFT ANALOG left ; PC: ←
case 214: // XBOX: LEFT STICK left
case 205: // XBOX: DPAD left
navigate(nodes, depth, rect, "left");
break;
case 38: // PS: DPAD and LEFT ANALOG up ; PC: ↑
case 211: // XBOX: LEFT STICK up
case 203: // XBOX: DPAD up
navigate(nodes, depth, rect, "up");
break;
case 39: // PS: DPAD and LEFT ANALOG right ; PC: →
case 213: // XBOX: LEFT STICK right
case 206: // XBOX: DPAD right
navigate(nodes, depth, rect, "right");
break;
case 40: // PS: DPAD and LEFT ANALOG down ; PC: ↓
case 212: // XBOX: LEFT STICK down
case 204: // XBOX: DPAD down
navigate(nodes, depth, rect, "down");
break;
default:
break;
}
}, 250);

 

Focus Logic

Action Creators

export const FOCUS_ADD_NODE = "FOCUS_ADD_NODE";
export const FOCUS_REMOVE_NODE = "FOCUS_REMOVE_NODE";
export const FOCUS_NODE = "FOCUS_NODE";
export const FOCUS_LOWER_DEPTH = "FOCUS_LOWER_DEPTH";
export const addNode = node => ({
type: FOCUS_ADD_NODE,
node
});
export const removeNode = id => ({
type: FOCUS_REMOVE_NODE,
id
});
export const focusNode = id => ({
type: FOCUS_NODE,
id
});
export const focusLowerDepth = () => ({
type: FOCUS_LOWER_DEPTH
});

State's shape (redux store)

{
nodes: {
loginButton: {
id: 'loginButton',
depth: 0,
rect: {
bottom: 954.390625,
height: 96,
left: 141.84375,
right: 345.8125,
top: 858.390625,
width: 203.96875,
x: 141.84375,
y: 858.390625
}
},
createAccountButton: {...}
},
currentId: 'loginButton',
previousId: 'createAccountButton',
currentDepth: 0,
depthsMainId: { 0: 'loginButton' }
}

 

Navigation Button example

<FocusContainer
id="loginButton"
render={({ refCallback, isFocused }) => (
<Button
ref={refCallback}
isFocused={isFocused}
onFocus={preloadLogin}
onKeyDown={e => onKeyEnter(e, () => history.push(paths.AUTH_LOGIN))}
hollow
>
<FormattedMessage {...loginToUbi.msg} />
</Button>
)}
/>;

 

Button styled-component

import styled from 'styled-components';
export default styled.button`
border: 2px solid
${props =>
props.isFocused
? props.theme.color.primary
: props.theme.color.greyDark2};
padding: ${props => props.theme.spacing.reg}px;
width: ${props => (props.fullWidth ? '100%' : 'auto')};
line-height: ${props => props.theme.lineHeight};
font-size: ${props => props.theme.font.subtitle};
font-weight: bold;
text-align: center;
text-transform: ${props => (props.uppercase ? 'uppercase' : 'none')};
background-color: ${props =>
props.hollow
? 'transparent'
: props.isFocused
? props.theme.color.primary
: props.theme.color.greyDark2};
color: ${props =>
props.hollow
? props.isFocused
? props.theme.color.primary
: props.theme.color.greyDark2
: props.theme.color.white};
border-radius: 32px;
`;

 

FocusContainer

import { Component } from "react";
import { connect } from "react-redux";
import { addNode, removeNode, focusNode } from "../actions/focus";
const mapStateToProps = (state, ownProps) => ({
nodes: state.focus.nodes,
isFocused: state.focus.currentId === ownProps.id,
isPreviousFocus: state.focus.previousId === ownProps.id
});
const mapDispatchToProps = dispatch => ({
addNode: node => dispatch(addNode(node)),
removeNode: id => dispatch(removeNode(id)),
focusNode: id => dispatch(focusNode(id))
});
const FocusContainer = connect(
mapStateToProps,
mapDispatchToProps
)(
class extends Component {
refCallback = ref => {
const {
nodes,
id,
depth,
toFocus,
addNode,
focusNode,
customRect
} = this.props;
if (id in nodes) return;
this.ref = ref;
addNode({
id,
depth,
rect: customRect || ref.getBoundingClientRect()
});
if (toFocus) focusNode(id);
};
componentWillUnmount() {
const { removeNode, id } = this.props;
removeNode(id);
}
componentDidUpdate(prevProps) {
const { isFocused, preventScroll } = this.props;
if (this.ref && isFocused && !prevProps.isFocused)
this.ref.focus({ preventScroll });
}
render() {
const { isPreviousFocus, isFocused } = this.props;
return this.props.render({
refCallback: ref => ref && this.refCallback(ref),
isFocused,
isPreviousFocus,
tabIndex: isFocused ? 0 : isPreviousFocus ? -1 : null
});
}
}
);
// PropTypes
export default FocusContainer;

 

Hooks Version ⚓

FocusProvider

const FocusProvider = props => {
const [state, dispatch] = useReducer(reducer, initialState);
const dispatchFocusNode = id => dispatch(focusNode(id));
const throttledHandleKeyDown = throttle(
event =>
handleKeyDown(
event,
dispatchFocusNode,
state.nodes,
state.currentId,
props.keyCodes,
props.disablePreventDefaultStrokes
),
props.throttle
);
useEffect(() => {
if (!props.disableOnKeyDown)
window.addEventListener("keydown", throttledHandleKeyDown);
else window.removeEventListener("keydown", throttledHandleKeyDown);
return () => window.removeEventListener("keydown", throttledHandleKeyDown);
});
return (
<FocusContext.Provider
value={{
...state,
addNode: node => dispatch(addNode(node)),
removeNode: id => dispatch(removeNode(id)),
focusNode: dispatchFocusNode,
focusLowerDepth: () => dispatch(focusLowerDepth())
}}
>
{props.children}
</FocusContext.Provider>
);
};

Custom Hook: useFocus

import { useEffect } from "react";
import useFocusContext from "./useFocusContext";
const refCallback = ({
ref,
nodes,
id,
depth,
toFocus,
addNode,
focusNode,
customRect
}) => {
if (id in nodes) return;
addNode({
id,
depth,
rect: customRect || ref.getBoundingClientRect()
});
if (toFocus) focusNode(id);
};
export default ({
id,
customRect,
depth = 0,
toFocus = false,
preventScroll = true
}) => {
if (!id) throw new Error("You must specify an unique id (string).");
const focus = useFocusContext();
const isFocused = id === focus.currentId;
const isPreviousFocus = id === focus.previousId;
let focusRef;
useEffect(
() => (isFocused && focusRef ? focusRef.focus({ preventScroll }) : undefined),
[isFocused]
);
useEffect(() => () => focus.removeNode(id), []);
return {
refCallback: ref => {
if (ref) {
focusRef = ref;
refCallback({
ref,
id,
depth,
toFocus,
customRect,
nodes: focus.nodes,
addNode: focus.addNode,
focusNode: focus.focusNode
});
}
},
isFocused,
isPreviousFocus,
tabIndex: isFocused ? 0 : isPreviousFocus ? -1 : null
};
};

 

Example

import React from "react";
import useFocus from "../hooks/useFocus";
export default () => {
const elt1Focus = useFocus({ id: "elt1", toFocus: true });
const elt2Focus = useFocus({ id: "elt2" });
return (
<>
<div
ref={elt1Focus.refCallback}
style={{ color: elt1Focus.isFocused ? "tomato" : "black" }}
tabIndex={elt1Focus.tabIndex}
role="button"
>
Elt1
</div>
<div
ref={elt2Focus.refCallback}
style={{ color: elt1Focus.isFocused ? "tomato" : "black" }}
tabIndex={elt2Focus.tabIndex}
role="button"
>
Elt2
</div>
</>
);
};

 

Merci

BTW those slides were made with mdx-deck âĪïļ