feat(client/curriculum): add dialogue animations (#52543)

This commit is contained in:
Tom
2023-12-15 09:29:45 -06:00
committed by GitHub
parent 2e9251fbc4
commit a31f6637d7
30 changed files with 2390 additions and 60 deletions
+75
View File
@@ -123,6 +123,41 @@ exports.createPages = function createPages({ graphql, actions, reporter }) {
superOrder
template
usesMultifileEditor
scene {
setup {
background
characters {
character
position {
x
y
z
}
}
audio {
filename
startTime
startTimestamp
finishTimestamp
}
alwaysShowDialogue
}
commands {
background
character
position {
x
y
z
}
startTime
finishTime
dialogue {
text
align
}
}
}
}
}
}
@@ -303,6 +338,7 @@ exports.createSchemaCustomization = ({ actions }) => {
prerequisites: [PrerequisiteChallenge]
msTrophyId: String
fillInTheBlank: FillInTheBlank
scene: Scene
}
type FileContents {
fileKey: String
@@ -325,6 +361,45 @@ exports.createSchemaCustomization = ({ actions }) => {
answer: String
feedback: String
}
type Scene {
setup: SceneSetup
commands: [SceneCommands]
}
type SceneSetup {
background: String
characters: [SetupCharacter]
audio: SetupAudio
alwaysShowDialogue: Boolean
}
type SetupCharacter {
character: String
position: CharacterPosition
opacity: Float
}
type SetupAudio {
filename: String
startTime: Float
startTimestamp: Float
finishTimestamp: Float
}
type SceneCommands {
background: String
character: String
position: CharacterPosition
opacity: Float
startTime: Float
finishTime: Float
dialogue: Dialogue
}
type Dialogue {
text: String
align: String
}
type CharacterPosition {
x: Float
y: Float
z: Float
}
`;
createTypes(typeDefs);
};
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
function AccessibilityIcon(
props: JSX.IntrinsicAttributes & React.SVGProps<SVGSVGElement>
): JSX.Element {
return (
<>
<svg
fill={props.fill || 'var(--gray-00)'}
xmlns='http://www.w3.org/2000/svg'
width='452.000000pt'
height='452.000000pt'
viewBox='0 0 452.000000 452.000000'
preserveAspectRatio='xMidYMid meet'
{...props}
>
<g
transform='translate(0.000000,452.000000) scale(0.100000,-0.100000)'
fill={props.fill || 'var(--gray-00)'}
stroke='none'
>
<path
d='M1980 4325 c-683 -95 -1267 -511 -1578 -1123 -153 -301 -222 -593
-222 -942 0 -349 69 -641 222 -942 209 -413 540 -735 963 -939 279 -135 564
-198 892 -199 348 0 645 69 945 222 625 317 1043 917 1128 1618 15 124 12 382
-5 517 -120 926 -862 1668 -1788 1788 -143 18 -424 18 -557 0z m539 -190 c854
-122 1514 -795 1621 -1655 13 -109 13 -331 0 -440 -60 -485 -295 -919 -665
-1228 -347 -291 -763 -442 -1215 -442 -306 0 -588 67 -854 203 -838 428 -1237
1397 -941 2285 65 195 187 421 312 577 299 376 733 626 1213 699 126 19 398
19 529 1z'
/>
<path
d='M2153 3876 c-218 -71 -320 -310 -221 -515 31 -63 107 -137 176 -169
49 -23 70 -27 152 -27 82 0 103 4 152 27 278 130 285 525 12 659 -55 27 -80
33 -147 36 -51 2 -98 -2 -124 -11z m183 -195 c97 -44 128 -171 63 -263 -24
-33 -95 -68 -139 -68 -44 0 -115 35 -139 68 -65 92 -34 219 63 263 23 10 57
19 76 19 19 0 53 -9 76 -19z'
/>
<path
d='M1075 3196 c-93 -44 -135 -112 -135 -218 1 -76 28 -137 82 -181 26
-21 143 -67 405 -161 l368 -131 3 -302 2 -302 -115 -460 c-63 -254 -115 -475
-115 -492 0 -120 104 -228 220 -229 75 0 121 18 171 69 46 46 47 50 170 418
68 205 126 373 129 373 3 0 61 -168 129 -372 123 -369 124 -373 170 -419 50
-51 96 -69 171 -69 116 1 220 109 220 229 0 17 -52 238 -115 492 l-115 460 2
301 3 302 368 131 c263 95 378 141 404 162 56 45 83 104 83 180 0 107 -42 175
-135 219 -87 41 -113 36 -489 -91 l-338 -115 -358 0 -357 0 -339 115 c-376
127 -402 132 -489 91z m451 -281 l352 -115 382 0 382 0 352 115 c193 63 354
115 358 115 4 0 14 -7 22 -16 17 -17 20 -42 8 -62 -5 -6 -197 -79 -428 -161
l-419 -149 -3 -384 -3 -383 117 -467 c95 -382 114 -471 104 -483 -16 -19 -44
-19 -59 -1 -15 17 -331 963 -331 989 0 36 -29 75 -65 87 -66 22 -135 -23 -135
-87 0 -26 -316 -972 -331 -989 -15 -18 -43 -18 -59 1 -10 12 9 101 104 483
l117 467 -3 384 -3 383 -420 149 c-230 82 -423 155 -427 162 -12 19 -9 44 8
61 8 9 18 16 22 16 4 0 165 -52 358 -115z'
/>
</g>
</svg>
</>
);
}
AccessibilityIcon.displayName = 'AccessibilityIcon';
export default AccessibilityIcon;
+64
View File
@@ -87,6 +87,69 @@ export interface VideoLocaleIds {
portuguese?: string;
}
// English types for animations
export interface Dialogue {
text: string;
align: 'left' | 'right' | 'center';
}
export interface CharacterPosition {
x?: number;
y?: number;
z?: number;
}
export interface SceneCommand {
background?: string;
character: string;
position?: CharacterPosition;
opacity?: number;
startTime: number;
finishTime?: number;
dialogue?: Dialogue;
}
export type Characters =
| 'Alice'
| 'Anna'
| 'Bob'
| 'Brian'
| 'David'
| 'Jake'
| 'James'
| 'Linda'
| 'Lisa'
| 'Maria'
| 'Sarah'
| 'Sophie'
| 'Tom';
export interface SetupCharacter {
character: Characters;
position: CharacterPosition;
opacity: number;
isTalking?: boolean;
}
export interface SetupAudio {
filename: string;
startTime: number;
startTimestamp?: number;
finishTimestamp?: number;
}
export interface SceneSetup {
background: string;
characters: SetupCharacter[];
audio: SetupAudio;
alwaysShowDialogue?: boolean;
}
export interface FullScene {
setup: SceneSetup;
commands: SceneCommand[];
}
export interface PrerequisiteChallenge {
id: string;
title: string;
@@ -146,6 +209,7 @@ export type ChallengeNode = {
question: Question;
assignments: string[];
required: Required[];
scene: FullScene;
solutions: {
[T in FileKey]: FileKeyChallenge;
};
@@ -0,0 +1,22 @@
.character-wrap {
position: absolute;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
transition: all 0.5s ease;
}
.character-wrap-hidden {
visibility: hidden;
}
.character-feature {
position: absolute;
max-width: 100%;
max-height: 100%;
top: 0;
left: 0;
object-fit: cover;
transform-origin: left;
}
@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { Characters, CharacterPosition } from '../../../../redux/prop-types';
import { characterAssets } from './scene-assets';
import './character.css';
interface CharacterProps {
position: CharacterPosition;
opacity: number;
name: Characters;
isBlinking: boolean;
isTalking: boolean;
}
interface CharacterStyles {
left?: string;
top?: string;
transform?: string;
opacity?: number;
}
export function Character({
position,
opacity,
name,
isBlinking,
isTalking
}: CharacterProps): JSX.Element {
const [eyesAreOpen, setEyesAreOpen] = useState(true);
const [mouthIsOpen, setMouthIsOpen] = useState(false);
useEffect(() => {
let blinkInterval: NodeJS.Timeout | null = null;
let talkInterval: NodeJS.Timeout | null = null;
if (isBlinking) {
const msBetweenIntervals = Math.floor(Math.random() * 3000) + 2000;
blinkInterval = setInterval(() => {
const msBlinkDelay = Math.floor(Math.random() * 1000);
setTimeout(() => {
setEyesAreOpen(false);
setTimeout(() => {
setEyesAreOpen(true);
}, 30); // always unblink after 30ms
}, msBlinkDelay);
}, msBetweenIntervals);
}
if (isTalking) {
talkInterval = setInterval(() => {
const openDuration = getRandomInt(100, 200);
const closeDuration = getRandomInt(300, 400);
setTimeout(() => {
setMouthIsOpen(true);
}, openDuration);
setTimeout(() => {
setMouthIsOpen(false);
}, closeDuration);
}, 300);
}
function getRandomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Clear intervals when component is unmounted or conditions change
return () => {
setEyesAreOpen(true);
setMouthIsOpen(false);
if (blinkInterval) clearInterval(blinkInterval);
if (talkInterval) clearInterval(talkInterval);
};
}, [isBlinking, isTalking]);
const characterWrapStyles: CharacterStyles = {
opacity
};
if (position.x) characterWrapStyles.left = `${position.x}%`;
if (position.y) characterWrapStyles.top = `${position.y}%`;
const characterFeatureStyles: CharacterStyles = {
transform: position.z
? `translate(-${50 * position.z}%) scale(${position.z})`
: `translate(-50%)`
};
const { base, brows, eyesOpen, eyesClosed, mouthOpen, mouthClosed, glasses } =
characterAssets[name];
return (
<div style={characterWrapStyles} className='character-wrap'>
<img
style={characterFeatureStyles}
className='character-feature'
src={base}
alt='Character Body'
/>
<img
style={characterFeatureStyles}
className='character-feature'
src={brows}
alt='Character Brows'
/>
<img
style={characterFeatureStyles}
className='character-feature'
src={eyesAreOpen ? eyesOpen : eyesClosed}
alt='Character Eyes'
/>
<img
style={characterFeatureStyles}
className='character-feature'
src={mouthIsOpen ? mouthOpen : mouthClosed}
alt='Character Mouth'
/>
{glasses && (
<img
style={characterFeatureStyles}
className='character-feature'
src={glasses}
alt='Character Glasses'
/>
)}
</div>
);
}
Character.displayName = 'Character';
export default Character;
@@ -0,0 +1,140 @@
// TODO: get domain from env
const domain = 'http://localhost:8080/';
export const sounds = `${domain}/sounds`;
export const images = `${domain}/images`;
export const backgrounds = `${images}/backgrounds`;
export const characters = `${images}/characters`;
const alice = `${characters}/alice`;
const anna = `${characters}/anna`;
const bob = `${characters}/bob`;
const brian = `${characters}/brian`;
const david = `${characters}/david`;
const jake = `${characters}/jake`;
const james = `${characters}/james`;
const linda = `${characters}/linda`;
const lisa = `${characters}/lisa`;
const maria = `${characters}/maria`;
const sarah = `${characters}/sarah`;
const sophie = `${characters}/sophie`;
const tom = `${characters}/tom`;
export const characterAssets = {
Alice: {
base: `${alice}/base.png`,
brows: `${alice}/brows-normal.png`,
eyesClosed: `${alice}/eyes-closed.png`,
eyesOpen: `${alice}/eyes-open.png`,
glasses: null,
mouthClosed: `${alice}/mouth-smile.png`,
mouthOpen: `${alice}/mouth-laugh.png`
},
Anna: {
base: `${anna}/base.png`,
brows: `${anna}/brows-normal.png`,
eyesClosed: `${anna}/eyes-closed.png`,
eyesOpen: `${anna}/eyes-open.png`,
glasses: null,
mouthClosed: `${anna}/mouth-smile.png`,
mouthOpen: `${anna}/mouth-laugh.png`
},
Bob: {
base: `${bob}/base.png`,
brows: `${bob}/brows-normal.png`,
eyesClosed: `${bob}/eyes-closed.png`,
eyesOpen: `${bob}/eyes-open.png`,
glasses: null,
mouthClosed: `${bob}/mouth-smile.png`,
mouthOpen: `${bob}/mouth-laugh.png`
},
Brian: {
base: `${brian}/base.png`,
brows: `${brian}/brows-neutral.png`,
eyesClosed: `${brian}/eyes-closed.png`,
eyesOpen: `${brian}/eyes-open.png`,
glasses: `${brian}/glasses.png`,
mouthClosed: `${brian}/mouth-smile.png`,
mouthOpen: `${brian}/mouth-laugh.png`
},
David: {
base: `${david}/base.png`,
brows: `${david}/brows-normal.png`,
eyesClosed: `${david}/eyes-closed.png`,
eyesOpen: `${david}/eyes-open.png`,
glasses: null,
mouthClosed: `${david}/mouth-smile.png`,
mouthOpen: `${david}/mouth-laugh.png`
},
Jake: {
base: `${jake}/base.png`,
brows: `${jake}/brows.png`,
eyesClosed: `${jake}/eyes-closed.png`,
eyesOpen: `${jake}/eyes-open.png`,
glasses: null,
mouthClosed: `${jake}/mouth-smile.png`,
mouthOpen: `${jake}/mouth-laugh.png`
},
James: {
base: `${james}/base.png`,
brows: `${james}/brows-normal.png`,
eyesClosed: `${james}/eyes-closed.png`,
eyesOpen: `${james}/eyes-open.png`,
glasses: `${james}/glasses.png`,
mouthClosed: `${james}/mouth-smile.png`,
mouthOpen: `${james}/mouth-laugh.png`
},
Linda: {
base: `${linda}/base.png`,
brows: `${linda}/brows-normal.png`,
eyesClosed: `${linda}/eyes-closed.png`,
eyesOpen: `${linda}/eyes-open.png`,
glasses: null,
mouthClosed: `${linda}/mouth-smile.png`,
mouthOpen: `${linda}/mouth-laugh.png`
},
Lisa: {
base: `${lisa}/base.png`,
brows: `${lisa}/brows-normal.png`,
eyesClosed: `${lisa}/eyes-closed.png`,
eyesOpen: `${lisa}/eyes-open.png`,
glasses: null,
mouthClosed: `${lisa}/mouth-smile.png`,
mouthOpen: `${lisa}/mouth-laugh.png`
},
Maria: {
base: `${maria}/base.png`,
brows: `${maria}/brows-normal.png`,
eyesClosed: `${maria}/eyes-closed.png`,
eyesOpen: `${maria}/eyes-open.png`,
glasses: `${maria}/glasses.png`,
mouthClosed: `${maria}/mouth-smile.png`,
mouthOpen: `${maria}/mouth-laugh.png`
},
Sarah: {
base: `${sarah}/base.png`,
brows: `${sarah}/brows-normal.png`,
eyesClosed: `${sarah}/eyes-closed.png`,
eyesOpen: `${sarah}/eyes-open.png`,
glasses: null,
mouthClosed: `${sarah}/mouth-smile.png`,
mouthOpen: `${sarah}/mouth-laugh.png`
},
Sophie: {
base: `${sophie}/base.png`,
brows: `${sophie}/brows-neutral.png`,
eyesClosed: `${sophie}/eyes-closed.png`,
eyesOpen: `${sophie}/eyes-open.png`,
glasses: `${sophie}/glasses.png`,
mouthClosed: `${sophie}/mouth-smile.png`,
mouthOpen: `${sophie}/mouth-laugh.png`
},
Tom: {
base: `${tom}/base.png`,
brows: `${tom}/brows-normal.png`,
eyesClosed: `${tom}/eyes-closed.png`,
eyesOpen: `${tom}/eyes-open.png`,
glasses: null,
mouthClosed: `${tom}/mouth-smile.png`,
mouthOpen: `${tom}/mouth-laugh.png`
}
};
@@ -0,0 +1,76 @@
.scene-wrapper {
overflow: hidden;
position: relative;
}
.scene-start-screen {
position: absolute;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
background-color: rgba(10, 10, 35, 0.5);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: var(--gray-00);
}
.scene-start-btn,
.scene-start-btn:hover,
.scene-start-btn:focus,
.scene-start-btn:active {
background-color: rgba(0, 0, 0, 0);
border: none;
}
.scene-a11y-btn {
position: absolute;
right: 5px;
bottom: 5px;
text-align: right;
display: flex;
align-items: center;
justify-content: center;
}
.scene-play-btn img {
max-width: 75%;
}
.scene-a11y-btn svg {
width: calc(50px + 3vw);
height: calc(50px + 3vw);
}
.scene-dialogue-wrap {
position: absolute;
bottom: 0;
padding: calc(5px + 0.25vw) calc(5px + 4vw);
background: rgba(10, 10, 35, 0.9);
font-size: calc(0.25vw + 0.75rem);
width: 100%;
min-height: calc(35px + 1vw + 2rem);
}
.scene-dialogue-label {
color: var(--blue-light);
}
.scene-dialogue-align-left {
text-align: left;
}
.scene-dialogue-align-right {
text-align: right;
}
.scene-dialogue-align-center {
text-align: center;
}
.scene-dialogue-text {
font-size: calc(0.25vw + 1rem);
padding: 5px 10px;
}
@@ -0,0 +1,221 @@
import React, { useEffect, useState, useRef } from 'react'; //, ReactElement } from 'react';
import { Col } from '@freecodecamp/ui';
import { FullScene } from '../../../../redux/prop-types';
import { Loader } from '../../../../components/helpers';
import AccessibilityIcon from '../../../../assets/icons/accessibility';
import { sounds, images, backgrounds } from './scene-assets';
import Character from './character';
import './scene.css';
export function Scene({ scene }: { scene: FullScene }): JSX.Element {
const { setup, commands } = scene;
const { audio, alwaysShowDialogue } = setup;
const audioTimestamp =
audio.startTimestamp !== null && audio.finishTimestamp !== null
? `#t=${audio.startTimestamp},${audio.finishTimestamp}`
: '';
const audioRef = useRef<HTMLAudioElement>(
new Audio(`${sounds}/${audio.filename}${audioTimestamp}`)
);
// on mount
useEffect(() => {
const { current } = audioRef;
current.addEventListener('canplaythrough', audioLoaded);
// on unmount
return () => {
const { current } = audioRef;
current.pause();
current.currentTime = 0;
current.removeEventListener('canplaythrough', audioLoaded);
};
}, [audioRef]);
const audioLoaded = () => {
setSceneIsReady(true);
};
const initBackground = setup.background;
const initDialogue = { label: '', text: '', align: 'left' };
const initCharacters = setup.characters.map(character => {
return {
...character,
opacity: character.opacity ?? 1,
isTalking: false
};
});
const [isPlaying, setIsPlaying] = useState(false);
const [sceneIsReady, setSceneIsReady] = useState(true);
const [showDialogue, setShowDialogue] = useState(false);
const [accessibilityOn, setAccessibilityOn] = useState(false);
const [characters, setCharacters] = useState(initCharacters);
const [dialogue, setDialogue] = useState(initDialogue);
const [background, setBackground] = useState(initBackground);
const playScene = () => {
setIsPlaying(true);
setShowDialogue(true);
commands.forEach((command, commandIndex) => {
// Start audio timeout
setTimeout(function () {
void audioRef.current.play();
}, audio.startTime * 1000);
// Start command timeout
setTimeout(() => {
if (command.background) setBackground(command.background);
setDialogue(
command.dialogue
? { ...command.dialogue, label: command.character }
: initDialogue
);
setCharacters(prevCharacters => {
const newCharacters = prevCharacters.map(character => {
if (character.character === command.character) {
return {
...character,
position: command.position ?? character.position,
opacity: command.opacity ?? character.opacity,
isTalking: command.dialogue ? true : false
};
}
return character;
});
return newCharacters;
});
}, command.startTime * 1000);
// Finish command timeout, only used when there's a dialogue
if (command.dialogue) {
setTimeout(
() => {
setCharacters(prevCharacters => {
const newCharacters = prevCharacters.map(character => {
if (character.character === command.character) {
return {
...character,
isTalking: false
};
}
return character;
});
return newCharacters;
});
},
(command.finishTime as number) * 1000
);
}
// Last command timeout
if (commandIndex === commands.length - 1) {
setTimeout(
() => {
finishScene();
},
command.finishTime
? command.finishTime * 1000 + 500
: command.startTime * 1000 + 500
);
}
});
};
const finishScene = () => {
audioRef.current.pause();
audioRef.current.src = `${sounds}/${audio.filename}${audioTimestamp}`;
audioRef.current.currentTime = audio.startTimestamp || 0;
setIsPlaying(false);
setShowDialogue(false);
setDialogue(initDialogue);
setCharacters(initCharacters);
setBackground(initBackground);
};
return (
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
<div
className='scene-wrapper'
style={{
backgroundImage: `url("${backgrounds}/${background}")`,
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center center',
aspectRatio: '16 / 9'
}}
>
{!sceneIsReady ? (
<Loader />
) : (
<>
{characters.map(
(
{ character, position = {}, opacity = 1, isTalking = false },
i
) => {
return (
<Character
key={i}
name={character}
position={position}
opacity={opacity}
isTalking={isTalking}
isBlinking={isPlaying}
/>
);
}
)}
{showDialogue && (alwaysShowDialogue || accessibilityOn) && (
<div
className={`scene-dialogue-wrap ${
dialogue.align ? `scene-dialogue-align-${dialogue.align}` : ''
}`}
>
<div className='scene-dialogue-label'>{dialogue.label}</div>
<div className='scene-dialogue-text'>{dialogue.text}</div>
</div>
)}
{!isPlaying && (
<div className='scene-start-screen'>
<button
className='scene-start-btn scene-play-btn'
onClick={playScene}
>
<img src={`${images}/play-button.png`} alt='Press Play' />
</button>
{!alwaysShowDialogue && (
<button
className='scene-start-btn scene-a11y-btn'
aria-label='Accessibility On/Off'
onClick={() => setAccessibilityOn(!accessibilityOn)}
>
<AccessibilityIcon
fill={
accessibilityOn ? 'var(--gray-00)' : 'var(--gray-15)'
}
/>
</button>
)}
</div>
)}
</>
)}
</div>
</Col>
);
}
Scene.displayName = 'Scene';
export default Scene;
@@ -13,12 +13,10 @@ import { createSelector } from 'reselect';
import { Container, Col, Row } from '@freecodecamp/ui';
// Local Utilities
import Loader from '../../../components/helpers/loader';
import Spacer from '../../../components/helpers/spacer';
import LearnLayout from '../../../components/layouts/learn';
import { ChallengeNode, ChallengeMeta } from '../../../redux/prop-types';
import Hotkeys from '../components/hotkeys';
import VideoPlayer from '../components/video-player';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import PrismFormatted from '../components/prism-formatted';
@@ -28,6 +26,7 @@ import {
openModal
} from '../redux/actions';
import { isChallengeCompletedSelector } from '../redux/selectors';
import Scene from '../components/scene/scene';
// Styles
import '../odin/show.css';
@@ -180,9 +179,9 @@ class ShowDialogue extends Component<ShowDialogueProps, ShowDialogueState> {
description,
superBlock,
block,
videoId,
fields: { blockName },
assignments
assignments,
scene
}
}
},
@@ -210,29 +209,16 @@ class ShowDialogue extends Component<ShowDialogueProps, ShowDialogueState> {
/>
<Container>
<Row>
{videoId && (
<Col lg={10} lgOffset={1} md={10} mdOffset={1}>
<Spacer size='medium' />
<div className='video-wrapper'>
{!this.state.videoIsLoaded ? (
<div className='video-placeholder-loader'>
<Loader />
</div>
) : null}
<VideoPlayer
onVideoLoad={this.onVideoLoad}
title={title}
videoId={videoId}
videoIsLoaded={this.state.videoIsLoaded}
/>
</div>
</Col>
)}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='medium' />
<h2>{title}</h2>
<PrismFormatted className={'line-numbers'} text={description} />
<Spacer size='medium' />
</Col>
<Scene scene={scene} />
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ObserveKeys>
<h2>{t('learn.assignments')}</h2>
<div className='video-quiz-options'>
@@ -328,6 +314,43 @@ export const query = graphql`
}
translationPending
assignments
scene {
setup {
background
characters {
character
position {
x
y
z
}
opacity
}
audio {
filename
startTime
startTimestamp
finishTimestamp
}
alwaysShowDialogue
}
commands {
background
character
position {
x
y
z
}
opacity
startTime
finishTime
dialogue {
text
align
}
}
}
}
}
}
@@ -32,6 +32,7 @@ import { isChallengeCompletedSelector } from '../redux/selectors';
// Styles
import '../video.css';
import './show.css';
import Scene from '../components/scene/scene';
// Redux Setup
const mapStateToProps = createSelector(
@@ -254,7 +255,8 @@ class ShowFillInTheBlank extends Component<
translationPending,
fields: { blockName },
fillInTheBlank: { sentence, blanks },
audioPath
audioPath,
scene
}
}
},
@@ -312,6 +314,11 @@ class ShowFillInTheBlank extends Component<
</audio>
</>
)}
</Col>
{scene && <Scene scene={scene} />}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<Spacer size='medium' />
<PrismFormatted text={instructions} />
<Spacer size='medium' />
@@ -430,6 +437,43 @@ export const query = graphql`
feedback
}
}
scene {
setup {
background
characters {
character
position {
x
y
z
}
opacity
}
audio {
filename
startTime
startTimestamp
finishTimestamp
}
alwaysShowDialogue
}
commands {
background
character
position {
x
y
z
}
opacity
startTime
finishTime
dialogue {
text
align
}
}
}
translationPending
audioPath
}
+45 -1
View File
@@ -21,6 +21,7 @@ import Hotkeys from '../components/hotkeys';
import VideoPlayer from '../components/video-player';
import CompletionModal from '../components/completion-modal';
import HelpModal from '../components/help-modal';
import Scene from '../components/scene/scene';
import PrismFormatted from '../components/prism-formatted';
import {
challengeMounted,
@@ -217,7 +218,8 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
fields: { blockName },
question: { text, answers, solution },
assignments,
audioPath
audioPath,
scene
}
}
},
@@ -292,6 +294,11 @@ class ShowOdin extends Component<ShowOdinProps, ShowOdinState> {
</>
)}
<Spacer size='medium' />
</Col>
{scene && <Scene scene={scene} />}
<Col md={8} mdOffset={2} sm={10} smOffset={1} xs={12}>
<ObserveKeys>
{assignments.length > 0 && (
<>
@@ -456,6 +463,43 @@ export const query = graphql`
}
solution
}
scene {
setup {
background
characters {
character
position {
x
y
z
}
opacity
}
audio {
filename
startTime
startTimestamp
finishTimestamp
}
alwaysShowDialogue
}
commands {
background
character
position {
x
y
z
}
opacity
startTime
finishTime
dialogue {
text
align
}
}
}
translationPending
assignments
audioPath
@@ -1,15 +1,142 @@
---
id: 651dd3e06ffb500e3f2ce478
title: "Dialogue 1: Maria Introduces Herself to Tom"
title: 'Dialogue 1: Maria Introduces Herself to Tom'
challengeType: 21
videoId: nLDychdBwUg
dashedName: dialogue-1-maria-introduces-herself-to-tom
---
# --description--
Watch the video above to understand the context of the upcoming lessons.
Watch the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Tom",
"position": { "x": 125, "y": 0, "z": 1 }
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1
},
"alwaysShowDialogue": true
},
"commands": [
{
"character": "Maria",
"position": { "x": 25, "y": 0, "z": 1 },
"startTime": 0
},
{
"character": "Tom",
"position": { "x": 70, "y": 0, "z": 1 },
"startTime": 0.5
},
{
"character": "Maria",
"startTime": 1,
"finishTime": 4.65,
"dialogue": {
"text": "Hello! You're the new graphic designer, right? I'm Maria, the team lead.",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 5.1,
"finishTime": 9.1,
"dialogue": {
"text": "Hi, that's right! I'm Tom McKenzie. It's a pleasure to meet you.",
"align": "right"
}
},
{
"character": "Maria",
"startTime": 9.7,
"finishTime": 12.6,
"dialogue": {
"text": "Welcome aboard, Tom! How do you like California so far?",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 12.7,
"finishTime": 15.7,
"dialogue": {
"text": "I like it. It's different from Texas, but I like it here.",
"align": "right"
}
},
{
"character": "Maria",
"startTime": 16.2,
"finishTime": 18.5,
"dialogue": {
"text": "Great! Let me show you to your desk.",
"align": "left"
}
},
{
"character": "Maria",
"startTime": 18.7,
"finishTime": 21.2,
"dialogue": {
"text": "Do you see the desk with a drawing tablet and a computer?",
"align": "left"
}
},
{
"character": "Maria",
"startTime": 21.4,
"finishTime": 22.6,
"dialogue": {
"text": "That's your workspace.",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 22.8,
"finishTime": 24.5,
"dialogue": {
"text": "Everything looks great.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 24.5,
"finishTime": 26.1,
"dialogue": {
"text": "Thanks for showing me around the place, Maria.",
"align": "right"
}
},
{
"character": "Tom",
"position": { "x": 125, "y": 0, "z": 1 },
"startTime": 26.6
},
{
"character": "Maria",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 27.1
}
]
}
```
@@ -3,30 +3,73 @@ id: 651dd5296ffb500e3f2ce479
title: Task 1
challengeType: 22
dashedName: task-1
audioPath: 1.1-1.mp3#t=1.6,3.3
---
<!--
AUDIO REFERENCE:
Maria: Hello! You are the new graphic designer, right?
Maria: Hello! You're the new graphic designer, right?
-->
# --description--
In English, when making introductions or identifying someone, you use the verb `to be`. In this case, `You are` is used to address the person Maria is talking to and affirmatively identify their occupation.
In English, contractions are commonly used to make speech sound more natural and fluent. `You're` is a contraction of `you are`.
Maria is introducing herself and confirming Tom's job role. `Are` is used in the present affirmative to make a statement.
This contraction is a combination of the pronoun `you` and the verb `are`, which is part of the verb `to be`. `Are` is used here in the present affirmative to make a statement or ask a question. This is a typical way to confirm someone's role or identity in English.
# --fillInTheBlank--
## --sentence--
`Hello, You _ the new graphic designer, right?`
`Hello, You_ the new graphic designer, right?`
## --blanks--
`are`
`'re`
### --feedback--
The verb `to be` is an irregular verb. When conjugated with the pronoun `you`, `be` becomes `are`. For example: `You are an English learner.`
In the audio, `you're` is used, which is a contraction of `you are`. The verb `to be` is irregular. When conjugated with `you`, it becomes `are`, as in `You are an English learner.` Here, `you're` expresses the same meaning.
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 0,
"finishTimestamp": 2.5
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.5,
"finishTime": 2.9,
"dialogue": {
"text": "Hello! You're the new graphic designer, right?",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 3.4
}
]
}
```
@@ -3,7 +3,6 @@ id: 651dd5386ffb500e3f2ce47a
title: Task 2
challengeType: 22
dashedName: task-2
audioPath: curriculum/js-music-player/We-Are-Going-to-Make-it.mp3
---
<!--
@@ -13,26 +12,62 @@ Maria: Hello! You are the new graphic designer, right?
# --description--
In English, to check or confirm something people sometimes use tag questions. For example, `You are a programmer, right?` Here, `right?` is used as a tag to check or confirm the previous statement.
In English, to check or confirm something people sometimes use tag questions. For example, `You're a programmer, right?` Here, `right?` is used as a tag to check or confirm the previous statement.
# --fillInTheBlank--
## --sentence--
`Hello, You _ the new graphic designer, _?`
`Hello, You're the new graphic designer, _?`
## --blanks--
`are`
### --feedback--
Pay attention to the verb in the sentence.
---
`right`
### --feedback--
Pay attention to the verb in the sentence.
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 0,
"finishTimestamp": 2.5
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.5,
"finishTime": 2.9,
"dialogue": {
"text": "Hello! You're the new graphic designer, right?",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 3.4
}
]
}
```
@@ -3,7 +3,6 @@ id: 6537e6ece93e5724eeb27c54
title: Task 3
challengeType: 19
dashedName: task-3
audioPath: curriculum/js-music-player/We-Are-Going-to-Make-it.mp3
---
<!--
@@ -56,3 +55,47 @@ Focus on the term Maria used to describe herself.
## --video-solution--
3
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 2.6,
"finishTimestamp": 4
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.5,
"finishTime": 2.2,
"dialogue": {
"text": "I'm Maria, the team lead.",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 2.7
}
]
}
```
@@ -3,7 +3,6 @@ id: 6543aa3df5f028dba112f275
title: Task 4
challengeType: 22
dashedName: task-4
audioPath: curriculum/js-music-player/We-Are-Going-to-Make-it.mp3
---
<!--
@@ -36,3 +35,47 @@ Focus on the term Maria used to describe herself.
### --feedback--
Focus on the term Maria used to describe herself.
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 2.6,
"finishTimestamp": 4
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.5,
"finishTime": 2.2,
"dialogue": {
"text": "I'm Maria, the team lead.",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 2.7
}
]
}
```
@@ -3,7 +3,6 @@ id: 6543aaa9f5f028dba112f276
title: Task 5
challengeType: 19
dashedName: task-5
audioPath: curriculum/js-music-player/We-Are-Going-to-Make-it.mp3
---
<!--
@@ -3,13 +3,230 @@ id: 656a2a7b05241026c429e3f0
title: "Dialogue 2: Tom Meets the Coworker Next To Him"
challengeType: 21
dashedName: dialogue-2-tom-meets-the-coworker-next-to-him
videoId: nLDychdBwUg
---
# --description--
Watch the video above to understand the context of the upcoming lessons.
Watch the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Sophie",
"position": { "x": 125, "y": 0, "z": 1 }
}
],
"audio": {
"filename": "1.1-2.mp3",
"startTime": 1
},
"alwaysShowDialogue": true
},
"commands": [
{
"character": "Tom",
"position": { "x": 25, "y": 0, "z": 1 },
"startTime": 0
},
{
"character": "Sophie",
"position": { "x": 70, "y": 0, "z": 1 },
"startTime": 0.5
},
{
"character": "Tom",
"startTime": 1,
"finishTime": 4.3,
"dialogue": {
"text": "Hi, there. I'm Tom. I'm the new graphic designer.",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 4.9,
"finishTime": 7.7,
"dialogue": {
"text": "Oh, hi Tom! I'm Sophie. I'm a developer.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 7.8,
"finishTime": 8.8,
"dialogue": {
"text": "Where are you from, Tom?",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 8.9,
"finishTime": 11.1,
"dialogue": {
"text": "I'm from Texas. How about you?",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 11.5,
"finishTime": 13.3,
"dialogue": {
"text": "I'm from here in California. Welcome aboard.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 13.7,
"finishTime": 16.4,
"dialogue": {
"text": "Thanks. Everybody is so nice around here.",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 16.6,
"finishTime": 20.6,
"dialogue": {
"text": "Hey, is this one of those standing desks? These are great!",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 20.7,
"finishTime": 23.7,
"dialogue": {
"text": "It's good to stand up a little instead of just sitting all the time.",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 23.9,
"finishTime": 27.3,
"dialogue": {
"text": "That's so true. I'm a bit inactive, sitting all the time.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 27.6,
"finishTime": 29.1,
"dialogue": {
"text": "This is a good alternative for me.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 29.7,
"finishTime": 32.6,
"dialogue": {
"text": "But hey, now your desk is just like my desk. You're in luck.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 33,
"finishTime": 36.5,
"dialogue": {
"text": "Oh, awesome. My computer and drawing tablet are great, too.",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 37.5,
"finishTime": 40.4,
"dialogue": {
"text": "Yeah. At this company, they're very attentive to these details.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 40.4,
"finishTime": 44.4,
"dialogue": {
"text": "You're going to like it here if you're into cutting-edge gadgets.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 44.5,
"finishTime": 50.2,
"dialogue": {
"text": "This is so cool. A standing desk, an ergonomic chair and an ergonomic mouse.",
"align": "left"
}
},
{
"character": "Tom",
"startTime": 50.2,
"finishTime": 53.7,
"dialogue": {
"text": "Man, everything is perfect. I'm in love with this place!",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 53.8,
"finishTime": 56.8,
"dialogue": {
"text": "So nice to have someone so energetic like you in the team.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 56.8,
"finishTime": 58,
"dialogue": {
"text": "Are you ready to begin?",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 58,
"finishTime": 60,
"dialogue": {
"text": "Yes, I sure am!",
"align": "left"
}
},
{
"character": "Sophie",
"position": { "x": 125, "y": 0, "z": 1 },
"startTime": 60.5
},
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 61
}
]
}
```
@@ -1,15 +1,178 @@
---
id: 656cbad538b114095fb14c0e
title: "Dialogue 3: Tom and Sophie Take a Lunch Break"
title: 'Dialogue 3: Tom and Sophie Take a Lunch Break'
challengeType: 21
dashedName: dialogue-3-tom-and-sophie-take-a-lunch-break
videoId: nLDychdBwUg
---
# --description--
What the video above to understand the context of the upcoming lessons.
What the video below to understand the context of the upcoming lessons.
# --assignment--
Watch the video
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Sophie",
"position": { "x": 125, "y": 0, "z": 1 }
}
],
"audio": {
"filename": "1.1-3.mp3",
"startTime": 1.2
},
"alwaysShowDialogue": true
},
"commands": [
{
"character": "Tom",
"position": { "x": 25, "y": 0, "z": 1 },
"startTime": 0
},
{
"character": "Sophie",
"position": { "x": 70, "y": 0, "z": 1 },
"startTime": 0.5
},
{
"character": "Tom",
"startTime": 1,
"finishTime": 4.4,
"dialogue": {
"text": "Wow, I'm so hungry. Is it lunch time?",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 5,
"finishTime": 6.4,
"dialogue": {
"text": "Yes, it is.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 6.6,
"finishTime": 8.5,
"dialogue": {
"text": "Are you eating here or are you going out?",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 9.1,
"finishTime": 10.8,
"dialogue": {
"text": "Today, I'm going out.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 11,
"finishTime": 14,
"dialogue": {
"text": "I can show you some places around here. Are you interested?",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 14,
"finishTime": 17.2,
"dialogue": {
"text": "Of course. Any favorite lunch spot around here, Sophie?",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 17.4,
"finishTime": 20.5,
"dialogue": {
"text": "I know a nice one. It's a café right down the street.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 20.5,
"finishTime": 21.3,
"dialogue": {
"text": "Is that OK for you?",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 21.5,
"finishTime": 22.7,
"dialogue": {
"text": "Sounds great!",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 23.1,
"finishTime": 25.2,
"dialogue": {
"text": "Yeah. It's nice to have a break from the office.",
"align": "right"
}
},
{
"character": "Tom",
"startTime": 25.4,
"finishTime": 28.7,
"dialogue": {
"text": "It is. Is the café within walking distance?",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 29.1,
"finishTime": 30.4,
"dialogue": {
"text": "Well, it's not far.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 30.5,
"finishTime": 32.1,
"dialogue": {
"text": "Come on. We can go together.",
"align": "right"
}
},
{
"character": "Sophie",
"position": { "x": 125, "y": 0, "z": 1 },
"startTime": 32.6
},
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 33.1
}
]
}
```
@@ -1,9 +1,8 @@
---
id: 656cd4b014d03a1baf452429
title: "Dialogue 4: Sophie Introduces Brian to Tom"
title: 'Dialogue 4: Sophie Introduces Brian to Tom'
challengeType: 21
dashedName: dialogue-4-sophie-introduces-brian-to-tom
videoId: nLDychdBwUg
---
# --description--
@@ -13,3 +12,156 @@ What the video above to understand the context of the upcoming lessons.
# --assignment--
Watch the video
# --scene--
```json
{
"setup": {
"background": "cafe.png",
"characters": [
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Sophie",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Brian",
"position": { "x": 125, "y": 0, "z": 1 }
}
],
"audio": {
"filename": "1.1-4.mp3",
"startTime": 1.3
},
"alwaysShowDialogue": true
},
"commands": [
{
"character": "Sophie",
"position": { "x": 40, "y": 0, "z": 1 },
"startTime": 0
},
{
"character": "Tom",
"position": { "x": 25, "y": 0, "z": 1 },
"startTime": 0.3
},
{
"character": "Brian",
"position": { "x": 75, "y": 0, "z": 1 },
"startTime": 0.7
},
{
"character": "Sophie",
"startTime": 1.3,
"finishTime": 4,
"dialogue": {
"text": "Oh, look who's here! Hey, Brian! How is everything?",
"align": "left"
}
},
{
"character": "Brian",
"startTime": 4.5,
"finishTime": 6.4,
"dialogue": {
"text": "Sophie! Great to see you here.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 6.6,
"finishTime": 9.8,
"dialogue": {
"text": "You, too. Brian, let me introduce you to Tom.",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 9.9,
"finishTime": 12.8,
"dialogue": {
"text": "Tom, this is Brian. He's a web developer.",
"align": "left"
}
},
{
"character": "Sophie",
"startTime": 13,
"finishTime": 17,
"dialogue": {
"text": "Brian, this is Tom. He's our new graphic designer and he is from Texas.",
"align": "left"
}
},
{
"character": "Brian",
"startTime": 17.2,
"finishTime": 19.4,
"dialogue": {
"text": "It is a pleasure to meet you, Tom.",
"align": "right"
}
},
{
"character": "Brian",
"startTime": 19.6,
"finishTime": 23.6,
"dialogue": {
"text": "Sophie is a great workmate. She's very kind and helpful.",
"align": "right"
}
},
{
"character": "Sophie",
"startTime": 23.9,
"finishTime": 25.4,
"dialogue": {
"text": "Oh, c'mon, Brian!",
"align": "left"
}
},
{
"character": "Brian",
"startTime": 25.8,
"finishTime": 26.8,
"dialogue": {
"text": "But it's true.",
"align": "right"
}
},
{
"character": "Brian",
"startTime": 27.1,
"finishTime": 29.4,
"dialogue": {
"text": "She's the person to go to if you need help!",
"align": "right"
}
},
{
"character": "Sophie",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 29.9
},
{
"character": "Tom",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 30.2
},
{
"character": "Brian",
"position": { "x": 125, "y": 0, "z": 1 },
"startTime": 30.6
}
]
}
```
@@ -1,9 +1,8 @@
---
id: 656d23d22a488510bca0e418
title: "Dialogue 5: End of the First Day"
title: 'Dialogue 5: End of the First Day'
challengeType: 21
dashedName: dialogue-5-end-of-the-first-day
videoId: nLDychdBwUg
---
# --description--
@@ -13,3 +12,167 @@ What the video above to understand the context of the upcoming lessons.
# --assignment--
Watch the video
# --scene--
```json
{
"setup": {
"background": "company1-reception.png",
"characters": [
{
"character": "Jake",
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Sarah",
"position": { "x": 125, "y": 0, "z": 1 }
}
],
"audio": {
"filename": "1.1-5.mp3",
"startTime": 1.3
},
"alwaysShowDialogue": true
},
"commands": [
{
"character": "Jake",
"position": { "x": 30, "y": 0, "z": 1 },
"startTime": 0
},
{
"character": "Sarah",
"position": { "x": 70, "y": 0, "z": 1 },
"startTime": 0.7
},
{
"character": "Jake",
"startTime": 1.7,
"finishTime": 3.5,
"dialogue": {
"text": "Hey. You're Sarah, right?",
"align": "left"
}
},
{
"character": "Jake",
"startTime": 3.6,
"finishTime": 5.4,
"dialogue": {
"text": "I'm Jake, from Security.",
"align": "left"
}
},
{
"character": "Jake",
"startTime": 5.5,
"finishTime": 7.3,
"dialogue": {
"text": "I'm here to give you your access card.",
"align": "left"
}
},
{
"character": "Sarah",
"startTime": 7.7,
"finishTime": 10,
"dialogue": {
"text": "Thanks, Jake. Is it contactless?",
"align": "right"
}
},
{
"character": "Jake",
"startTime": 10.2,
"finishTime": 13.4,
"dialogue": {
"text": "No, it isn't. It's the good-old swipe at the door.",
"align": "left"
}
},
{
"character": "Jake",
"startTime": 13.5,
"finishTime": 17,
"dialogue": {
"text": "When you hear the click, it's unlocked, and you can get in or out.",
"align": "left"
}
},
{
"character": "Sarah",
"startTime": 17.9,
"finishTime": 19.4,
"dialogue": {
"text": "Good to know. Thank you!",
"align": "right"
}
},
{
"character": "Jake",
"startTime": 19.6,
"finishTime": 22,
"dialogue": {
"text": "Well, it's five o'clock.",
"align": "left"
}
},
{
"character": "Jake",
"startTime": 22.1,
"finishTime": 24.2,
"dialogue": {
"text": "I guess this is it for your first day.",
"align": "left"
}
},
{
"character": "Jake",
"startTime": 24.5,
"finishTime": 25.5,
"dialogue": {
"text": "How was it?",
"align": "left"
}
},
{
"character": "Sarah",
"startTime": 25.9,
"finishTime": 27.5,
"dialogue": {
"text": "Good, really good.",
"align": "right"
}
},
{
"character": "Sarah",
"startTime": 27.8,
"finishTime": 28.8,
"dialogue": {
"text": "See you tomorrow, then?",
"align": "right"
}
},
{
"character": "Jake",
"startTime": 29.0,
"finishTime": 31.0,
"dialogue": {
"text": "Sure. Have a great evening. See you!",
"align": "left"
}
},
{
"character": "Jake",
"position": { "x": -25, "y": 0, "z": 1 },
"startTime": 31.5
},
{
"character": "Sarah",
"position": { "x": 125, "y": 0, "z": 1 },
"startTime": 32
}
]
}
```
@@ -5,6 +5,11 @@ exports[`challenge schema Notify mobile team BEFORE updating snapshot 1`] = `
Joi.objectId = require('joi-objectid')(Joi);
const { challengeTypes } = require('../../shared/config/challenge-types');
const {
availableCharacters,
availableBackgrounds,
availableAudios
} = require('./scene-assets');
const slugRE = new RegExp('^[a-z0-9-]+$');
const slugWithSlashRE = new RegExp('^[a-z0-9-/]+$');
@@ -29,6 +34,55 @@ const prerequisitesJoi = Joi.object().keys({
title: Joi.string().required()
});
const positionJoi = Joi.object().keys({
x: Joi.number().required(),
y: Joi.number().required(),
z: Joi.number().required()
});
const setupCharacterJoi = Joi.object().keys({
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi.required(),
opacity: Joi.number()
});
const setupAudioJoi = Joi.object().keys({
filename: Joi.string()
.valid(...availableAudios)
.required(),
startTime: Joi.number().required(),
startTimestamp: Joi.number(),
finishTimestamp: Joi.number()
});
const setupJoi = Joi.object().keys({
background: Joi.string()
.valid(...availableBackgrounds)
.required(),
characters: Joi.array().items(setupCharacterJoi).min(1).required(),
audio: setupAudioJoi.required(),
alwaysShowDialogue: Joi.boolean()
});
const DialogueJoi = Joi.object().keys({
text: Joi.string().required(),
align: Joi.string()
});
const commandJoi = Joi.object().keys({
background: Joi.string().valid(...availableBackgrounds),
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi,
opacity: Joi.number(),
startTime: Joi.number().required(),
finishTime: Joi.number(),
dialogue: DialogueJoi
});
const schema = Joi.object()
.keys({
audioPath: Joi.string(),
@@ -92,7 +146,7 @@ const schema = Joi.object()
}),
// video challenges only:
videoId: Joi.when('challengeType', {
is: [challengeTypes.video, challengeTypes.dialogue],
is: [challengeTypes.video],
then: Joi.string().required()
}),
videoLocaleIds: Joi.when('challengeType', {
@@ -136,6 +190,10 @@ const schema = Joi.object()
then: Joi.array().items(Joi.string()).required(),
otherwise: Joi.array().items(Joi.string())
}),
scene: Joi.object().keys({
setup: setupJoi.required(),
commands: Joi.array().items(commandJoi)
}),
solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)),
superBlock: Joi.string().regex(slugWithSlashRE),
superOrder: Joi.number(),
+59 -1
View File
@@ -2,6 +2,11 @@ const Joi = require('joi');
Joi.objectId = require('joi-objectid')(Joi);
const { challengeTypes } = require('../../shared/config/challenge-types');
const {
availableCharacters,
availableBackgrounds,
availableAudios
} = require('./scene-assets');
const slugRE = new RegExp('^[a-z0-9-]+$');
const slugWithSlashRE = new RegExp('^[a-z0-9-/]+$');
@@ -26,6 +31,55 @@ const prerequisitesJoi = Joi.object().keys({
title: Joi.string().required()
});
const positionJoi = Joi.object().keys({
x: Joi.number().required(),
y: Joi.number().required(),
z: Joi.number().required()
});
const setupCharacterJoi = Joi.object().keys({
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi.required(),
opacity: Joi.number()
});
const setupAudioJoi = Joi.object().keys({
filename: Joi.string()
.valid(...availableAudios)
.required(),
startTime: Joi.number().required(),
startTimestamp: Joi.number(),
finishTimestamp: Joi.number()
});
const setupJoi = Joi.object().keys({
background: Joi.string()
.valid(...availableBackgrounds)
.required(),
characters: Joi.array().items(setupCharacterJoi).min(1).required(),
audio: setupAudioJoi.required(),
alwaysShowDialogue: Joi.boolean()
});
const DialogueJoi = Joi.object().keys({
text: Joi.string().required(),
align: Joi.string()
});
const commandJoi = Joi.object().keys({
background: Joi.string().valid(...availableBackgrounds),
character: Joi.string()
.valid(...availableCharacters)
.required(),
position: positionJoi,
opacity: Joi.number(),
startTime: Joi.number().required(),
finishTime: Joi.number(),
dialogue: DialogueJoi
});
const schema = Joi.object()
.keys({
audioPath: Joi.string(),
@@ -89,7 +143,7 @@ const schema = Joi.object()
}),
// video challenges only:
videoId: Joi.when('challengeType', {
is: [challengeTypes.video, challengeTypes.dialogue],
is: [challengeTypes.video],
then: Joi.string().required()
}),
videoLocaleIds: Joi.when('challengeType', {
@@ -133,6 +187,10 @@ const schema = Joi.object()
then: Joi.array().items(Joi.string()).required(),
otherwise: Joi.array().items(Joi.string())
}),
scene: Joi.object().keys({
setup: setupJoi.required(),
commands: Joi.array().items(commandJoi)
}),
solutions: Joi.array().items(Joi.array().items(fileJoi).min(1)),
superBlock: Joi.string().regex(slugWithSlashRE),
superOrder: Joi.number(),
+92
View File
@@ -0,0 +1,92 @@
const availableCharacters = [
'Alice',
'Anna',
'Bob',
'Brian',
'David',
'Jake',
'James',
'Linda',
'Lisa',
'Maria',
'Sarah',
'Sophie',
'Tom'
];
const availableBackgrounds = [
'bedroom-empty.png',
'cafe.png',
'chaos.png',
'classroom.png',
'company1-boardroom.png',
'company1-breakroom.png',
'company1-center.png',
'company1-dining.png',
'company1-lydia-cubicle.png',
'company1-parking.png',
'company1-reception.png',
'company1-roof.png',
'company2-boardroom.png',
'company2-breakroom.png',
'company2-center.png',
'company2-dining.png',
'company2-lydia-cubicle.png',
'company2-parking.png',
'company2-reception.png',
'company2-roof.png',
'company3-boardmeeting.png',
'company3-breakroom.png',
'company3-center.png',
'company3-dining.png',
'company3-lydia-cubicle.png',
'company3-parking.png',
'company3-reception.png',
'company3-roof.png',
'cubicle.png',
'desk.png',
'farm.png',
'hacker-space-cafe.png',
'hacker-space.png',
'hall-audience.png',
'hall.png',
'interview-room1.png',
'interview-room2.png',
'interview-room3.png',
'kid-home.png',
'kitchen.png',
'laptop-screen.png',
'living-room.png',
'office-cafe.png',
'office.png',
'park1.png',
'park2.png',
'park3.png',
'park4.png',
'pong-field.png',
'tunnel.png'
];
const availableAudios = [
'1.1-1.mp3',
'1.1-2.mp3',
'1.1-3.mp3',
'1.1-4.mp3',
'1.1-5.mp3',
'1.2-1.mp3',
'1.2-2.mp3',
'1.2-3.mp3',
'1.2-4.mp3',
'1.2-5.mp3',
'1.3-1.mp3',
'1.3-2.mp3',
'1.3-3.mp3',
'1.3-4.mp3',
'1.3-5.mp3'
];
module.exports = {
availableCharacters,
availableBackgrounds,
availableAudios
};
+98 -1
View File
@@ -27,7 +27,6 @@ Before you work on the curriculum, you would need to set up some tooling to help
[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/freeCodeCamp/freeCodeCamp)
### How to work on practice projects
The practice projects have some additional tooling to help create new projects and steps. To read more, see [these docs](how-to-work-on-practice-projects.md)
@@ -200,6 +199,104 @@ Solution for the second blank. Example:
`right`
If no feedback is here, a generic "wrong answer" message will be shown.
# --scene--
```json
// # --scene-- can only consist of a single json object
{
// Setup the scene. Properties not marked optional are required.
"setup": {
// Background file to start the scene. A list of scene asset filenames can be found here: https://github.com/freeCodeCamp/cdn/pull/233/files
"background": "company2-center.png",
// Array of all characters that will appear in the scene
"characters": [
{
// Name of character. See list of available characters in scene-assets.tsx
"character": "Maria",
// Where to start the character. Maria will start off screen to the left
"position": { "x": -25, "y": 0, "z": 1 }
},
{
"character": "Tom",
// Tom will start 70% from the left of the screen and 1.5 times regular size
"position": { "x": 70, "y": 0, "z": 1.5 },
// Optional, defaults to 1. Tom will start invisible
"opacity": 0
}
],
"audio": {
// Audio filename
"filename": "1.1-1.mp3",
// Seconds after the scene starts before the audio starts playing
"startTime": 1.3,
// Optional. Timestamp of the audio file where it starts playing from.
"startTimestamp": 0,
// Optional. Timestamp of the audio file where is stops playing. If these two aren't used, the whole audio file will play.
"finishTimestamp": 8.4
},
// Optional, defaults to false. Use this for the long dialogues. It stops the accessibility icon from showing which gives campers the option to show or hide the dialogue text
"alwaysShowDialogue": true
},
// Array of commands that make up the scene
"commands": [
{
// Character that will have an action for this command
"character": "Maria",
// Optional, defaults to previous value. Maria will move to 25% from the left of the screen. The movement takes 0.5 seconds
"position": { "x": 25, "y": 0, "z": 1 },
// When the command will start. Zero seconds after the camper presses play
"startTime": 0
},
{
"character": "Tom",
// Optional, defaults to previous value. Tom will fade into view. The transition take 0.5 seconds. Movement and Opacity transitions take 0.5 seconds
"opacity": 1,
// Tom will fade into view 0.5 seconds into the scene (immediately after Maria finishes moving on screen)
"startTime": 0.5
},
{
"character": "Maria",
// When the command starts: Maria will start saying this line 1.3 seconds into the scene. Note that this is the same time as the audio.startTime above. It doesn't have to match that (maybe there's a pause at the begninning of the audio or something)
"startTime": 1.3,
// The character will stop moving their mouth at the finishTime
"finishTime": 4.95,
"dialogue": {
// Text that will appear if the dialogue is visible
"text": "Hello! You're the new graphic designer, right? I'm Maria, the team lead.",
// Where the dialogue text will be aligned. Can be 'left', 'center', or 'right'
"align": "left"
}
},
{
// background will change to this at 5.4 seconds into the scene
"background": "company2-breakroom.png",
"character": "Tom",
"startTime": 5.4,
"finishTime": 9.4,
"dialogue": {
"text": "Hi, that's right! I'm Tom McKenzie. It's a pleasure to meet you.",
// Tom's text will be aligned to the right since he is on the right side of the screen
"align": "right"
}
},
{
"character": "Tom",
// Tom will fade to 0 opacity
"opacity": 0,
// I like to move characters off screen or fade them 0.5 second after the last talking command
"startTime": 9.9
},
{
"character": "Maria",
// Maria will slide back off the screen to the left
"position": { "x": -25, "y": 0, "z": 1 },
// The animation will stop playing 0.5 seconds after the 'finishTime' of the last command - or 0.5 seconds after 'startTime' if 'finishTime' isn't there.
"startTime": 10.4
}
]
}
```
````
> [!NOTE]
@@ -0,0 +1,47 @@
# --description--
This challenge has a scene.
# --scene--
```json
{
"setup": {
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"position": { "x": 50, "y": 0, "z": 1.5 },
"opacity": 0
}
],
"audio": {
"filename": "1.1-1.mp3",
"startTime": 1,
"startTimestamp": 2.6,
"finishTimestamp": 4
}
},
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0
},
{
"character": "Maria",
"startTime": 0.7,
"finishTime": 2.4,
"dialogue": {
"text": "I'm Maria, the team lead.",
"align": "center"
}
},
{
"character": "Maria",
"opacity": 0,
"startTime": 3.4
}
]
}
```
@@ -18,6 +18,60 @@ a container directive
}
`;
exports[`challenge parser it should parse md with a scene 1`] = `
{
"assignments": [],
"description": "<section id="description">
<p>This challenge has a scene.</p>
</section>",
"scene": {
"commands": [
{
"character": "Maria",
"opacity": 1,
"startTime": 0,
},
{
"character": "Maria",
"dialogue": {
"align": "center",
"text": "I'm Maria, the team lead.",
},
"finishTime": 2.4,
"startTime": 0.7,
},
{
"character": "Maria",
"opacity": 0,
"startTime": 3.4,
},
],
"setup": {
"audio": {
"filename": "1.1-1.mp3",
"finishTimestamp": 4,
"startTime": 1,
"startTimestamp": 2.6,
},
"background": "company2-center.png",
"characters": [
{
"character": "Maria",
"opacity": 0,
"position": {
"x": 50,
"y": 0,
"z": 1.5,
},
},
],
},
},
"solutions": [],
"tests": [],
}
`;
exports[`challenge parser it should parse video questions 1`] = `
{
"assignments": [],
@@ -58,4 +58,11 @@ describe('challenge parser', () => {
);
expect(parsed).toMatchSnapshot();
});
it('it should parse md with a scene', async () => {
const parsed = await parseMD(
path.resolve(__dirname, '__fixtures__/scene.md')
);
expect(parsed).toMatchSnapshot();
});
});
+2
View File
@@ -14,6 +14,7 @@ const addAssignment = require('./plugins/add-assignment');
const replaceImports = require('./plugins/replace-imports');
const restoreDirectives = require('./plugins/restore-directives');
const tableAndStrikeThrough = require('./plugins/table-and-strikethrough');
const addScene = require('./plugins/add-scene');
// by convention, anything that adds to file.data has the name add<name>.
const processor = unified()
@@ -48,6 +49,7 @@ const processor = unified()
.use(addFillInTheBlank)
.use(addVideoQuestion)
.use(addAssignment)
.use(addScene)
.use(addTests)
.use(addText, ['description', 'instructions', 'notes']);
@@ -0,0 +1,25 @@
const getAllBetween = require('./utils/between-headings');
function plugin() {
return transformer;
function transformer(tree, file) {
const sceneNodes = getAllBetween(tree, '--scene--');
if (sceneNodes.length > 0) {
if (sceneNodes.length !== 1) {
throw Error('You can only have one item in a scene, a JSON array.');
}
if (sceneNodes[0].type !== 'code' || sceneNodes[0].lang !== 'json') {
throw Error('A scene must have a ```json code block');
}
// throws if we can't parse it.
const sceneJson = JSON.parse(sceneNodes[0].value);
file.data.scene = sceneJson;
}
}
}
module.exports = plugin;