diff --git a/app/core/Router.coffee b/app/core/Router.coffee index 13caacf6c..7cffebca8 100644 --- a/app/core/Router.coffee +++ b/app/core/Router.coffee @@ -66,6 +66,7 @@ module.exports = class CocoRouter extends Backbone.Router 'editor/level/:levelID': go('editor/level/LevelEditView') 'editor/thang': go('editor/thang/ThangTypeSearchView') 'editor/thang/:thangID': go('editor/thang/ThangTypeEditView') + 'editor/campaign/:campaignID': go('editor/campaign/CampaignEditorView') 'employers': go('EmployersView') @@ -84,7 +85,7 @@ module.exports = class CocoRouter extends Backbone.Router 'multiplayer': go('MultiplayerView') 'play-old': go('play/MainPlayView') # This used to be 'play'. - 'play': go('play/WorldMapView') + 'play': go('play/CampaignView') 'play/ladder/:levelID': go('ladder/LadderView') 'play/ladder': go('ladder/MainLadderView') 'play/level/:levelID': go('play/level/PlayLevelView') diff --git a/app/core/treema-ext.coffee b/app/core/treema-ext.coffee index 22bdfc6ac..cb41d61fd 100644 --- a/app/core/treema-ext.coffee +++ b/app/core/treema-ext.coffee @@ -313,7 +313,7 @@ class InternationalizationNode extends TreemaNode.nodeMap.object class LatestVersionCollection extends CocoCollection -class LatestVersionReferenceNode extends TreemaNode +module.exports.LatestVersionReferenceNode = class LatestVersionReferenceNode extends TreemaNode searchValueTemplate: '<input placeholder="Search" /><div class="treema-search-results"></div>' valueClass: 'treema-latest-version' url: '/db/article' @@ -383,7 +383,11 @@ class LatestVersionReferenceNode extends TreemaNode m = CocoModel.getReferencedModel(@getData(), @workingSchema) data = @getData() if _.isString data # LatestVersionOriginalReferenceNode just uses original - m = @settings.supermodel.getModelByOriginal(m.constructor, data) + if m.schema().properties.version + m = @settings.supermodel.getModelByOriginal(m.constructor, data) + else + # get by id + m = @settings.supermodel.getModel(m.constructor, data) else m = @settings.supermodel.getModelByOriginalAndMajorVersion(m.constructor, data.original, data.majorVersion) if @instance and not m @@ -434,7 +438,7 @@ class LatestVersionReferenceNode extends TreemaNode selected = @getSelectedResultEl() return not selected.length -class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode +module.exports.LatestVersionOriginalReferenceNode = class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode # Just for saving the original, not the major version. saveChanges: -> selected = @getSelectedResultEl() @@ -443,6 +447,15 @@ class LatestVersionOriginalReferenceNode extends LatestVersionReferenceNode @data = fullValue.attributes.original @instance = fullValue +module.exports.IDReferenceNode = class IDReferenceNode extends LatestVersionReferenceNode + # Just for saving the _id + saveChanges: -> + selected = @getSelectedResultEl() + return unless selected.length + fullValue = selected.data('value') + @data = fullValue.attributes._id + @instance = fullValue + class LevelComponentReferenceNode extends LatestVersionReferenceNode # HACK: this list of properties is needed by the thang components edit view and config views. # need a better way to specify this, or keep the search models from bleeding into those diff --git a/app/models/Campaign.coffee b/app/models/Campaign.coffee new file mode 100644 index 000000000..fec933a31 --- /dev/null +++ b/app/models/Campaign.coffee @@ -0,0 +1,10 @@ +CocoModel = require './CocoModel' +schema = require 'schemas/models/campaign.schema' + +module.exports = class Campaign extends CocoModel + @className: 'Campaign' + @schema: schema + urlRoot: '/db/campaign' + saveBackups: true + @denormalizedLevelProperties: _.keys(_.omit(schema.properties.levels.additionalProperties.properties, ['unlocks', 'position', 'rewards'])) + @denormalizedCampaignProperties: ['name', 'i18n', 'description', 'slug'] diff --git a/app/schemas/models/campaign.schema.coffee b/app/schemas/models/campaign.schema.coffee new file mode 100644 index 000000000..77eeddb42 --- /dev/null +++ b/app/schemas/models/campaign.schema.coffee @@ -0,0 +1,121 @@ +c = require './../schemas' + +CampaignSchema = c.object() +c.extendNamedProperties CampaignSchema # name first + +_.extend CampaignSchema.properties, { + i18n: {type: 'object', title: 'i18n', format: 'i18n', props: ['name', 'body']} + + ambientSound: c.object {}, + mp3: { type: 'string', format: 'sound-file' } + ogg: { type: 'string', format: 'sound-file' } + + backgroundImage: c.array {}, { + type: 'object' + additionalProperties: false + properties: { + image: { type: 'string', format: 'image-file' } + width: { type: 'number' } + } + } + backgroundColor: { type: 'string' } + backgroundColorTransparent: { type: 'string' } + + adjacentCampaigns: { type: 'object', format: 'campaigns', additionalProperties: { + title: 'Campaign' + type: 'object' + format: 'campaign' + properties: { + #- denormalized from other Campaigns, either updated automatically or fetched dynamically + id: { type: 'string', format: 'hidden' } + name: { type: 'string', format: 'hidden' } + description: { type: 'string', format: 'hidden' } + i18n: { type: 'object', format: 'hidden' } + slug: { type: 'string', format: 'hidden' } + + #- normal properties + position: c.point2d() + rotation: { type: 'number', format: 'degrees' } + color: { type: 'string' } + showIfUnlocked: { type: 'string', links: [{rel: 'db', href: '/db/level/{($)}/version'}], format: 'latest-version-original-reference' } + } + }} + + levels: { type: 'object', format: 'levels', additionalProperties: { + title: 'Level' + type: 'object' + format: 'level' + additionalProperties: false + + # key is the original property + properties: { + #- denormalized from Level + name: { type: 'string', format: 'hidden' } + description: { type: 'string', format: 'hidden' } + requiresSubscription: { type: 'boolean' } + type: {'enum': ['campaign', 'ladder', 'ladder-tutorial', 'hero', 'hero-ladder', 'hero-coop']} + slug: { type: 'string', format: 'hidden' } + original: { type: 'string', format: 'hidden' } + adventurer: { type: 'boolean' } + practice: { type: 'boolean' } + disableSpaces: { type: 'boolean' } + hidesSubmitUntilRun: { type: 'boolean' } + hidesPlayButton: { type: 'boolean' } + hidesRunShortcut: { type: 'boolean' } + hidesHUD: { type: 'boolean' } + hidesSay: { type: 'boolean' } + hidesCodeToolbar: { type: 'boolean' } + hidesRealTimePlayback: { type: 'boolean' } + backspaceThrottle: { type: 'boolean' } + lockDefaultCode: { type: 'boolean' } + moveRightLoopSnippet: { type: 'boolean' } + realTimeSpeedFactor: { type: 'number' } + autocompleteFontSizePx: { type: 'number' } + + requiredCode: c.array {}, { + type: 'string' + } + suspectCode: c.array {}, { + type: 'object' + properties: { + name: { type: 'string' } + pattern: { type: 'string' } + } + } + + requiredGear: { type: 'object', additionalProperties: { + type: 'array' + items: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' } + }} + restrictedGear: { type: 'object', additionalProperties: { + type: 'array' + items: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' } + }} + allowedHeroes: { type: 'array', items: { + type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' + }} + + #- denormalized from Achievements + rewards: { type: 'array', items: { + type: 'object' + additionalProperties: false + properties: + achievement: { type: 'string', links: [{rel: 'db', href: '/db/achievement/{{$}}'}], format: 'achievement' } + item: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' } + hero: { type: 'string', links: [{rel: 'db', href: '/db/thang.type/{($)}/version'}], format: 'latest-version-original-reference' } + level: { type: 'string', links: [{rel: 'db', href: '/db/level/{($)}/version'}], format: 'latest-version-original-reference' } + type: { enum: ['heroes', 'items', 'levels'] } + }} + + #- normal properties + position: c.point2d() + } + + }} +} + + +c.extendBasicProperties CampaignSchema, 'campaign' +c.extendTranslationCoverageProperties CampaignSchema + +module.exports = CampaignSchema diff --git a/app/schemas/models/level.coffee b/app/schemas/models/level.coffee index 93d6b513e..78e81acf5 100644 --- a/app/schemas/models/level.coffee +++ b/app/schemas/models/level.coffee @@ -296,6 +296,42 @@ _.extend LevelSchema.properties, requiresSubscription: {title: 'Requires Subscription', description: 'Whether this level is available to subscribers only.', type: 'boolean'} tasks: c.array {title: 'Tasks', description: 'Tasks to be completed for this level.', default: (name: t for t in defaultTasks)}, c.task + # Admin flags + adventurer: { type: 'boolean' } + practice: { type: 'boolean' } + disableSpaces: { type: 'boolean' } + hidesSubmitUntilRun: { type: 'boolean' } + hidesPlayButton: { type: 'boolean' } + hidesRunShortcut: { type: 'boolean' } + hidesHUD: { type: 'boolean' } + hidesSay: { type: 'boolean' } + hidesCodeToolbar: { type: 'boolean' } + hidesRealTimePlayback: { type: 'boolean' } + backspaceThrottle: { type: 'boolean' } + lockDefaultCode: { type: 'boolean' } + moveRightLoopSnippet: { type: 'boolean' } + realTimeSpeedFactor: { type: 'number' } + autocompleteFontSizePx: { type: 'number' } + requiredCode: c.array {}, { + type: 'string' + } + suspectCode: c.array {}, { + type: 'object' + properties: { + name: { type: 'string' } + pattern: { type: 'string' } + } + } + requiredGear: { type: 'object', additionalProperties: { + type: 'string' + }} + restrictedGear: { type: 'object', additionalProperties: { + type: 'string' + }} + allowedHeroes: { type: 'array', items: { + type: 'string' + }} + c.extendBasicProperties LevelSchema, 'level' c.extendSearchableProperties LevelSchema c.extendVersionedProperties LevelSchema, 'level' diff --git a/app/styles/editor/campaign/campaign-editor-view.sass b/app/styles/editor/campaign/campaign-editor-view.sass new file mode 100644 index 000000000..cdee91359 --- /dev/null +++ b/app/styles/editor/campaign/campaign-editor-view.sass @@ -0,0 +1,28 @@ +#campaign-editor-view + #left-column + position: absolute + top: 0 + bottom: 0 + left: 0 + width: 25% + margin-right: 1200px + + .treema-root + max-height: 100% + overflow: scroll + + #right-column + position: absolute + top: 0 + bottom: 0 + right: 0 + width: 75% + + #campaign-level-view + position: absolute + top: 0 + left: 0 + right: 0 + bottom: 0 + background-color: white + z-index: 3 \ No newline at end of file diff --git a/app/styles/play/campaign-view.sass b/app/styles/play/campaign-view.sass new file mode 100644 index 000000000..802206b8a --- /dev/null +++ b/app/styles/play/campaign-view.sass @@ -0,0 +1,495 @@ +@import "app/styles/mixins" +@import "app/styles/bootstrap/variables" + +$mapHeight: 1536 +$forestMapWidth: 2500 +$dungeonMapWidth: 2350 +$desertMapWidth: 2350 +$desertMapSeaBackground: rgba(113, 186, 208, 1) +$desertMapSeaBackgroundTransparent: rgba(113, 186, 208, 0) +$forestMapSeaBackground: rgba(113, 186, 208, 1) +$forestMapSeaBackgroundTransparent: rgba(113, 186, 208, 0) +$dungeonMapCaveBackground: rgba(68, 54, 45, 1) +$dungeonMapCaveBackgroundTransparent: rgba(68, 54, 45, 0) +$levelDotWidth: 2% +$levelDotHeight: $levelDotWidth * $forestMapWidth / $mapHeight +$levelDotZ: $levelDotHeight * 0.25 +$levelDotHoverZ: $levelDotZ * 2 +$levelDotShadowWidth: 0.8 * $levelDotWidth +$levelDotShadowHeight: 0.8 * $levelDotHeight +$levelClickRadius: 40px +$gameControlSize: 80px +$gameControlMargin: 30px + ++keyframes(levelStartedPulse) + from + @include box-shadow(0px 0px 4px #333) + margin-bottom: -$levelDotHeight / 3 + $levelDotZ + 50% + @include box-shadow(0px 0px 22px skyblue) + margin-bottom: -$levelDotHeight / 3 + ($levelDotHoverZ + $levelDotZ) / 2 + to + @include box-shadow(0px 0px 4px #333) + margin-bottom: -$levelDotHeight / 3 + $levelDotZ + +#campaign-view + width: 100% + height: 100% + position: absolute + + .gradient + position: absolute + z-index: 0 + + &.horizontal-gradient + left: 0 + right: 0 + height: 3% + + &.vertical-gradient + top: 0 + bottom: 0 + width: 3% + + &.top-gradient + top: 0 + + &.right-gradient + right: 0 + + &.bottom-gradient + bottom: 0 + + &.left-gradient + left: 0 + + &.desert + background-color: $desertMapSeaBackground + + .top-gradient + background: linear-gradient(to bottom, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%) + + .right-gradient + background: linear-gradient(to left, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%) + + .bottom-gradient + background: linear-gradient(to top, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%) + + .left-gradient + background: linear-gradient(to right, $desertMapSeaBackground 0%, $desertMapSeaBackgroundTransparent 100%) + + &.forest + background-color: $forestMapSeaBackground + + .top-gradient + background: linear-gradient(to bottom, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%) + + .right-gradient + background: linear-gradient(to left, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%) + + .bottom-gradient + background: linear-gradient(to top, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%) + + .left-gradient + background: linear-gradient(to right, $forestMapSeaBackground 0%, $forestMapSeaBackgroundTransparent 100%) + + &.dungeon + background-color: $dungeonMapCaveBackground + + .top-gradient + background: linear-gradient(to bottom, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%) + + .right-gradient + background: linear-gradient(to left, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%) + + .bottom-gradient + background: linear-gradient(to top, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%) + + .left-gradient + background: linear-gradient(to right, $dungeonMapCaveBackground 0%, $dungeonMapCaveBackgroundTransparent 100%) + + .map + position: relative + + .map-background + width: 100% + height: 100% + background-size: 100% + @include user-select(none) + + &.map-dungeon + background-image: url('/images/pages/play/map_dungeon_1920.jpg') + @media screen and ( max-width: 1366px ) + background-image: url('/images/pages/play/map_dungeon_1366.jpg') + + &.map-forest + background-image: url('/images/pages/play/map_forest_1920.jpg') + @media screen and ( max-width: 1366px ) + background-image: url('/images/pages/play/map_forest_1366.jpg') + + &.map-desert + background-image: url('/images/pages/play/map_desert_1920.jpg') + @media screen and ( max-width: 1366px ) + background-image: url('/images/pages/play/map_desert_1366.jpg') + + .level, .level-shadow + position: absolute + border-radius: 100% + -webkit-transform: scaleY(0.75) + transform: scaleY(0.75) + + .level + z-index: 2 + width: $levelDotWidth + height: $levelDotHeight + margin-left: -0.5 * $levelDotWidth + margin-bottom: -$levelDotHeight / 3 + $levelDotZ + border: 2px groove white + @include transition(margin-bottom 0.5s ease) + + &.disabled, &.locked + background-image: url(/images/pages/game-menu/lock.png) + background-size: 75% + background-repeat: no-repeat + background-position: 50% 50% + opacity: 0.7 + + a + cursor: default + + &.next + width: 2 * $levelDotWidth + height: 2 * $levelDotHeight + margin-left: -0.5 * 2 * $levelDotWidth + margin-bottom: -2 * $levelDotHeight / 3 + 2 * $levelDotZ + + &.started, &.next + border: 3px solid lightgreen + @include box-shadow(0px 0px 35px skyblue) + + // Would be cool, but kills performance, since we have to re-render all the time. + //&:not(:hover) + // -webkit-animation-name: levelStartedPulse + // -webkit-animation-duration: 3s + // -webkit-animation-iteration-count: infinite + + &.complete + border: 3px solid gold + @include box-shadow(0px 0px 35px skyblue) + + img.banner + position: absolute + bottom: 38% + left: -50% + width: 200% + pointer-events: none + + img.star + width: 100% + bottom: 7% + position: absolute + pointer-events: none + + .glyphicon-star + position: absolute + color: lightblue + font-size: 21px + left: 1.5px + + &.started .glyphicon-star + left: 0.5px + + img.hero-portrait + width: 120% + position: absolute + bottom: 75% + left: 75% + border: 1px solid black + border-radius: 100% + background: white + + + .level-shadow + z-index: 1 + width: $levelDotShadowWidth + height: $levelDotShadowHeight + margin-left: -0.5 * $levelDotShadowWidth + margin-bottom: -$levelDotShadowHeight / 3 + background-color: black + @include box-shadow(0px 0px 10px black) + @include opacity(0.75) + + &.next + width: 2 * $levelDotShadowWidth + height: 2 * $levelDotShadowHeight + margin-left: -0.5 * 2 * $levelDotShadowWidth + margin-bottom: -2 * $levelDotShadowHeight / 3 + + .level:hover + // TODO: This rotate stops Firefox from flickering, but also disables the scaleY(0.75) + // TODO: The dot looks like it's jumping. + // TODO: -moz-transform: scaleY(0.75) didn't do anything + // TODO: Does not break Chrome's oval. + -moz-transform: rotate(0) + margin-bottom: -$levelDotHeight / 3 + $levelDotHoverZ + @include box-shadow(0px 0px 35px skyblue) + + &.next + margin-bottom: -2 * $levelDotHeight / 3 + 2 * $levelDotHoverZ + + .level + a + display: block + padding: $levelClickRadius + margin-left: -0.5 * $levelClickRadius + margin-top: -0.5 * $levelClickRadius + border-radius: $levelClickRadius + + &.next a + padding: 2 * $levelClickRadius + margin-left: 2 * -0.5 * $levelClickRadius + margin-top: 2 * -0.5 * $levelClickRadius + border-radius: 2 * $levelClickRadius + + .tooltip + z-index: 2 + + .level-info-container + display: none + position: absolute + z-index: 3 + padding: 10px + border-width: 16px 12px + // Using modernizr-mixin for compat detection + @include yep(borderimage) + border-style: solid + border-image: url(/images/level/popover_border_background.png) 16 12 fill round + @include nope(borderimage) + background-color: rgb(247, 242, 218) + + .level-info.complete h3:after + content: " - Complete!" + color: green + + .level-info.started h3:after + content: " - Started" + color: desaturate(green, 50%) + + .level-info + h3 + margin-top: 0 + margin-bottom: 0px + + .level-description + color: black + text-shadow: 0 1px 0 white + + .campaign-label + text-shadow: 0 1px 0 white + + .start-level + display: block + margin: 10px auto 0 auto + width: 200px + + .campaign-switch + color: purple + position: absolute + z-index: 1 + font-size: 2vw + text-shadow: 0 0 0.3vw white, 0 0 0.3vw white + + &:hover + text-decoration: none + + &#desert-link + left: 90% + top: 18.5% + transform: scaleY(-1.5) scaleX(1.5) + + &#forest-back-link + left: 2% + top: 70.5% + transform: rotate(216deg) + + &#forest-link + left: 94.5% + top: 7% + transform: rotate(-35deg) + + &#dungeon-link + left: 9% + top: 54.5% + transform: rotate(180deg) + color: fuchsia + + .game-controls + position: absolute + right: 1% + bottom: 1% + z-index: 3 + + .btn + &:not(:first-child) + margin-left: $gameControlMargin + width: $gameControlSize + height: $gameControlSize + + background: url(/images/pages/play/menu_icons.png) no-repeat + + position: relative + img + position: absolute + left: 0 + top: 0 + width: 100% + height: 100% + + background-size: cover + @include transition(0.5s ease) + @include box-shadow(2px 2px 4px black) + border: 0 + border-radius: 12px + // IE9 shows a blank white button with this MS gradient filter in place + filter: none + + &:hover + @include box-shadow(0 0 12px #bbf) + + &:active + @include box-shadow(0 0 20px white) + + &.heroes + background-position: (-1 * $gameControlSize) 0px + &.achievements + background-position: (-2 * $gameControlSize) 0px + &.account + background-position: (-3 * $gameControlSize) 0px + &.settings + background-position: (-4 * $gameControlSize) 0px + &.gems + background-position: (-5 * $gameControlSize) 0px + + .tooltip + font-size: 24px + + .tooltip-arrow + display: none + + .user-status + position: absolute + bottom: 16px + left: 8px + text-align: center + font-size: 24px + color: white + text-shadow: 0px 2px 1px black, 0px -2px 1px black, -2px 0px 1px black, 2px 0px 1px black + height: 32px + line-height: 32px + + .user-status-line + position: relative + + button.btn.btn-illustrated + margin-left: 10px + min-width: 90px + height: 32px + color: white + + .gem, .player-level-icon, .player-hero-icon + position: absolute + top: 1px + + #gems-count + margin-left: 40px + + .player-level + margin-left: 34px + + .player-name + margin-left: 45px + + $spriteSheetSize: 30px + + .player-level-icon, .player-hero-icon + background: transparent url(/images/pages/play/play-spritesheet.png) + background-size: cover + background-position: (-2 * $spriteSheetSize) 0 + display: inline-block + width: 30px + height: 30px + margin: 0px 2px + + .player-hero-icon + margin-left: 10px + background-position: (-4 * $spriteSheetSize) 0 + + &.knight + background-position: (-5 * $spriteSheetSize) 0 + &.librarian + background-position: (-6 * $spriteSheetSize) 0 + &.ninja + background-position: (-7 * $spriteSheetSize) 0 + &.potion-master + background-position: (-8 * $spriteSheetSize) 0 + &.samurai + background-position: (-9 * $spriteSheetSize) 0 + &.trapper + background-position: (-10 * $spriteSheetSize) 0 + &.forest-archer + background-position: (-11 * $spriteSheetSize) 0 + &.sorcerer + background-position: (-12 * $spriteSheetSize) 0 + + + #volume-button + position: absolute + left: 1% + top: 1% + padding: 3px 8px + @include opacity(0.75) + + &:hover + @include opacity(1.0) + + .glyphicon + display: none + font-size: 32px + + &.vol-up .glyphicon.glyphicon-volume-up + display: inline-block + + &.vol-off .glyphicon.glyphicon-volume-off + display: inline-block + @include opacity(0.50) + &:hover + @include opacity(0.75) + + &.vol-down .glyphicon.glyphicon-volume-down + display: inline-block + + #campaign-status + position: absolute + left: 0 + top: 15px + width: 100% + margin: 0 + text-align: center + color: rgb(254,188,68) + font-size: 30px + text-shadow: black 2px 2px 0, black -2px -2px 0, black 2px -2px 0, black -2px 2px 0, black 2px 0px 0, black 0px -2px 0, black -2px 0px 0, black 0px 2px 0 + + +body:not(.ipad) #campaign-view + .level-info-container + pointer-events: none + + + +body.ipad #campaign-view + // iPad only supports up to Kithgard Gates for now. + .campaign-switch + display: none + + .old-levels + display: none diff --git a/app/templates/editor/campaign/campaign-editor-view.jade b/app/templates/editor/campaign/campaign-editor-view.jade new file mode 100644 index 000000000..2515ed484 --- /dev/null +++ b/app/templates/editor/campaign/campaign-editor-view.jade @@ -0,0 +1,44 @@ +extends /templates/base + +block header + if campaign.loading + nav.navbar.navbar-default(role='navigation')#campaign-editor-top-nav + .container-fluid + ul.nav.navbar-nav + li + a(href="/") + span.glyphicon-home.glyphicon + else + nav.navbar.navbar-default(role='navigation')#campaign-editor-top-nav + ul.nav.navbar-nav + li + a(href="/") + span.glyphicon-home.glyphicon + + ul.nav.navbar-nav.navbar-right + if me.isAdmin() + li#save-button + a + span.glyphicon-floppy-disk.glyphicon + li.dropdown + a(data-toggle='dropdown') + span.glyphicon-chevron-down.glyphicon + ul.dropdown-menu + li.dropdown-header Actions + li(class=anonymous ? "disabled": "") + a(data-toggle="coco-modal", data-target="modal/RevertModal", data-i18n="editor.revert")#revert-button Revert + li(class=anonymous ? "disabled": "") + a(data-i18n="editor.pop_i18n")#pop-level-i18n-button Populate i18n + li.divider + li.dropdown-header Info + +block outer_content + .outer-content + #left-column + #campaign-treema + + #right-column + #campaign-view + #campaign-level-view.hidden + +block footer diff --git a/app/templates/editor/campaign/campaign-level-view.jade b/app/templates/editor/campaign/campaign-level-view.jade new file mode 100644 index 000000000..fd2723d52 --- /dev/null +++ b/app/templates/editor/campaign/campaign-level-view.jade @@ -0,0 +1,4 @@ +.jumbotron + .button.close(type="button", aria-hidden="true") × + h1= level.get('name') + p= level.get('description') diff --git a/app/templates/editor/campaign/save-campaign-modal.jade b/app/templates/editor/campaign/save-campaign-modal.jade new file mode 100644 index 000000000..40633074b --- /dev/null +++ b/app/templates/editor/campaign/save-campaign-modal.jade @@ -0,0 +1,21 @@ +extends /templates/core/modal-base + +block modal-header-content + h3 Save Changes to Campaign + +block modal-body-content + if !modelsToSave.models.length + .alert.alert-info No changes + + for model in modelsToSave.models + .panel.panel-default + .panel-heading + span.panel-title.spr= model.get('name') + span.text-muted= model.constructor.className + .panel-body + .delta-view(data-model-id=model.id) + +block modal-footer + .modal-footer + button(data-dismiss="modal", data-i18n="common.cancel").btn Cancel + button.btn.btn-primary#save-button Save diff --git a/app/templates/play/campaign-view.jade b/app/templates/play/campaign-view.jade new file mode 100644 index 000000000..6bcb6ad80 --- /dev/null +++ b/app/templates/play/campaign-view.jade @@ -0,0 +1,99 @@ +.map + .gradient.horizontal-gradient.top-gradient + .gradient.vertical-gradient.right-gradient + .gradient.horizontal-gradient.bottom-gradient + .gradient.vertical-gradient.left-gradient + .map-background(class="map-"+mapType alt="", draggable="false") + + each level in levels + if !level.hidden + - var next = nextLevel && level.slug === nextLevel; + div(style="left: #{level.position.x}%; bottom: #{level.position.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.original] || "", data-level-id=level.original, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : '')) + if level.unlocksHero && !level.unlockedHero + img.hero-portrait(src=level.unlocksHero.img) + a(href=level.type == 'hero' ? '#' : level.disabled ? "/play" : "/play/#{level.levelPath || 'level'}/#{level.original}", disabled=level.disabled, data-level-id=level.original, data-level-path=level.levelPath || 'level', data-level-name=level.name) + if level.requiresSubscription + img.star(src="/images/pages/play/star.png") + if levelStatusMap[level.original] === 'complete' + img.banner(src="/images/pages/play/level-banner-complete.png") + if levelStatusMap[level.original] === 'started' + img.banner(src="/images/pages/play/level-banner-started.png") + div(style="left: #{level.position.x}%; bottom: #{level.position.y}%", class="level-shadow" + (next ? " next" : "") + " " + levelStatusMap[level.original] || "") + .level-info-container(data-level-id=level.original, data-level-path=level.levelPath || 'level', data-level-name=level.name) + div(class="level-info " + (levelStatusMap[level.original] || "")) + h3= level.name + (level.disabled ? " (Coming soon!)" : (level.locked ? " (Locked)" : "")) + .level-description= level.description + if level.disabled + p + span.spr(data-i18n="play.awaiting_levels_adventurer_prefix") We release five levels per week. + a.spr(href="/contribute/adventurer") + strong(data-i18n="play.awaiting_levels_adventurer") Sign up as an Adventurer + span.spl(data-i18n="play.awaiting_levels_adventurer_suffix") to be the first to play new levels. + + - var playCount = levelPlayCountMap[level.original] + if playCount && playCount.sessions > 20 + div + span.spr #{playCount.sessions} + span(data-i18n="play.players") players + span.spr , #{Math.round(playCount.playtime / 3600)} + span(data-i18n="play.hours_played") hours played + .campaign-label= campaign.get('name') + if isIPadApp && !level.disabled && !level.locked + button.btn.btn-success.btn-lg.start-level(data-i18n="common.play") Play + + for adjacentCampaign in adjacentCampaigns + a + span.glyphicon.glyphicon-share-alt.campaign-switch(href="/play/"+adjacentCampaign.slug, style=adjacentCampaign.style, title=adjacentCampaign.name, data-campaign-id=adjacentCampaign.id) + +.game-controls.header-font + button.btn.items(data-toggle='coco-modal', data-target='play/modal/PlayItemsModal', data-i18n="[title]play.items") + button.btn.heroes(data-toggle='coco-modal', data-target='play/modal/PlayHeroesModal', data-i18n="[title]play.heroes") + button.btn.achievements(data-toggle='coco-modal', data-target='play/modal/PlayAchievementsModal', data-i18n="[title]play.achievements") + if me.get('anonymous') === false || me.get('iosIdentifierForVendor') || isIPadApp + button.btn.gems(data-toggle='coco-modal', data-target='play/modal/BuyGemsModal', data-i18n="[title]play.buy_gems") + if me.isAdmin() + button.btn.account(data-toggle='coco-modal', data-target='play/modal/PlayAccountModal', data-i18n="[title]play.account") + button.btn.settings(data-toggle='coco-modal', data-target='play/modal/PlaySettingsModal', data-i18n="[title]play.settings") + else if me.get('anonymous', true) + button.btn.settings(data-toggle='coco-modal', data-target='core/AuthModal', data-i18n="[title]play.settings") + // Don't show these things, they are bad and take us out of the game. Just wait until the new ones work. + //else + // a.btn.achievements(href="/user/#{me.getSlugOrID()}/stats", data-i18n="[title]play.achievements") + // a.btn.account(href="/user/#{me.getSlugOrID()}", data-i18n="[title]play.account") + // a.btn.settings(href='/account', data-i18n="[title]play.settings") + +.user-status.header-font + .user-status-line + span.gem.gem-30 + span#gems-count.spr= me.gems() + span.player-level-icon + span.player-level.spr= me.level() + span.player-hero-icon + if me.get('anonymous') + span.player-name.spr(data-i18n="play.anonymous") Anonymous Player + button.btn.btn-illustrated.login-button.btn-warning(data-i18n="login.log_in") + button.btn.btn-illustrated.signup-button.btn-danger(data-i18n="signup.sign_up") + else + span.player-name.spr= me.get('name') + button#logout-button.btn.btn-illustrated.btn-warning(data-i18n="login.log_out") Log Out + if me.isPremium() + button.btn.btn-illustrated.btn-primary(data-i18n="nav.contact", data-toggle="coco-modal", data-target="core/ContactModal") Contact + + +button.btn.btn-lg.btn-inverse#volume-button(title="Adjust volume") + .glyphicon.glyphicon-volume-off + .glyphicon.glyphicon-volume-down + .glyphicon.glyphicon-volume-up + +//h1#campaign-status +// if mapType == 'dungeon' +// span.spr(data-i18n="play.campaign_dungeon") +// else if mapType == 'forest' +// span.spr(data-i18n="play.campaign_forest") +// | - +// if requiresSubscription +// span.spl(data-i18n="play.subscription_required") +// else if mapType == 'dungeon' +// span.spl(data-i18n="play.free") +// else +// span.spl(data-i18n="play.subscribed") \ No newline at end of file diff --git a/app/templates/play/world-map-view.jade b/app/templates/play/world-map-view.jade index ccc189642..2a88a63d9 100644 --- a/app/templates/play/world-map-view.jade +++ b/app/templates/play/world-map-view.jade @@ -8,7 +8,7 @@ - var seenNext = nextLevel; each level in campaign.levels if !level.hidden - - var next = level.id == nextLevel || (!seenNext && levelStatusMap[level.id] != "complete" && !level.locked && !level.disabled); + - var next = level.id == nextLevel || (!seenNext && levelStatusMap[level.id] != "complete" && !level.locked && !level.disabled && !editorMode); - seenNext = seenNext || next; div(style="left: #{level.x}%; bottom: #{level.y}%; background-color: #{level.color}", class="level" + (next ? " next" : "") + (level.disabled ? " disabled" : "") + (level.locked ? " locked" : "") + " " + levelStatusMap[level.id] || "", data-level-id=level.id, title=level.name + (level.disabled ? ' (Coming Soon to Adventurers)' : '')) if level.unlocksHero && !level.unlockedHero diff --git a/app/views/editor/campaign/CampaignEditorView.coffee b/app/views/editor/campaign/CampaignEditorView.coffee new file mode 100644 index 000000000..58976b6ec --- /dev/null +++ b/app/views/editor/campaign/CampaignEditorView.coffee @@ -0,0 +1,944 @@ +RootView = require 'views/core/RootView' +Campaign = require 'models/Campaign' +Level = require 'models/Level' +Achievement = require 'models/Achievement' +ThangType = require 'models/ThangType' +CampaignView = require 'views/play/CampaignView' +CocoCollection = require 'collections/CocoCollection' +treemaExt = require 'core/treema-ext' +utils = require 'core/utils' +SaveCampaignModal = require './SaveCampaignModal' +RelatedAchievementsCollection = require 'collections/RelatedAchievementsCollection' +CampaignLevelView = require './CampaignLevelView' + +achievementProject = ['related', 'rewards', 'name', 'slug'] +thangTypeProject = ['name', 'original', 'slug'] + +module.exports = class CampaignEditorView extends RootView + id: "campaign-editor-view" + template: require 'templates/editor/campaign/campaign-editor-view' + className: 'editor' + + events: + 'click #save-button': 'onClickSaveButton' + + constructor: (options, @campaignHandle) -> + super(options) + + # MIGRATION CODE +# for level in levels +# _.extend level, options[level.id] +# level.slug = level.id +# delete level.id +# delete level.nextLevels +# level.position = { x: level.x, y: level.y } +# delete level.x +# delete level.y +# if level.unlocksHero +# level.unlocks = [{ +# original: level.unlocksHero.originalID +# type: 'hero' +# }] +# delete level.unlocksHero +# campaign.levels[level.original] = level +# @campaign = new Campaign(campaign) + #------------------------------------------------ + + @campaign = new Campaign({_id:@campaignHandle}) + + #--------------- temporary migration to change thang type slugs to originals + #- should keep around though for loading the names of items and heroes that are referenced + #- just load names instead of slugs, though + @sluggyThangs = new Backbone.Collection() + @listenToOnce @campaign, 'sync', -> + slugs = [] + for level in _.values(@campaign.get('levels')) + slugs = slugs.concat(_.values(level.requiredGear)) if level.requiredGear + slugs = slugs.concat(_.values(level.restrictedGear)) if level.restrictedGear + slugs = slugs.concat(level.allowedHeroes) if level.allowedHeroes + slugs = _.uniq _.flatten slugs + for slug in slugs + thangType = new ThangType() + thangType.setProjection(thangTypeProject) + if utils.isID slug + thangType.setURL("/db/thang.type/#{slug}/version") + else + thangType.setURL("/db/thang.type/#{slug}") + @supermodel.loadModel(thangType, 'thang') + @sluggyThangs.add(thangType) + #--------------- + @supermodel.loadModel(@campaign, 'campaign') + + @levels = new CocoCollection([], { + model: Level + url: "/db/campaign/#{@campaignHandle}/levels" + project: Campaign.denormalizedLevelProperties + }) + @supermodel.loadCollection(@levels, 'levels') + + @achievements = new CocoCollection([], { + model: Achievement + url: "/db/campaign/#{@campaignHandle}/achievements" + project: achievementProject + }) + @supermodel.loadCollection(@achievements, 'achievements') + + @toSave = new Backbone.Collection() + @listenToOnce @campaign, 'sync', @onFundamentalLoaded + @listenToOnce @levels, 'sync', @onFundamentalLoaded + @listenToOnce @achievements, 'sync', @onFundamentalLoaded + + onFundamentalLoaded: -> + # load any levels which + return unless @campaign.loaded and @levels.loaded and @achievements.loaded + for level in _.values(@campaign.get('levels')) + model = @levels.findWhere(original: level.original) + if not model + model = new Level({}) + model.setProjection Campaign.denormalizedLevelProperties + model.setURL("/db/level/#{level.original}/version") + @levels.add @supermodel.loadModel(model, 'level').model + achievements = new RelatedAchievementsCollection level.original + achievements.setProjection achievementProject + @supermodel.loadCollection achievements, 'achievements' + @listenToOnce achievements, 'sync', -> + @achievements.add(achievements.models) + + + onLoaded: -> + @toSave.add @campaign if @campaign.hasLocalChanges() + campaignLevels = $.extend({}, @campaign.get('levels')) + for level in @levels.models + levelOriginal = level.get('original') + campaignLevel = campaignLevels[levelOriginal] + continue if not campaignLevel + + #--------------- temporary migrations + if campaignLevel.restrictedGear + for slot, value of campaignLevel.restrictedGear + if _.isString(value) + campaignLevel.restrictedGear[slot] = [value] + # + if campaignLevel.requiredGear + for slot, value of campaignLevel.requiredGear + if _.isString(value) + campaignLevel.requiredGear[slot] = [value] + # + if campaignLevel.requiredGear + for gear in _.values(campaignLevel.requiredGear) + for slug, index in gear + thang = @sluggyThangs.findWhere({slug: slug}) + continue unless thang + gear[index] = thang.get('original') + # + if campaignLevel.restrictedGear + for gear in _.values(campaignLevel.restrictedGear) + for slug, index in gear + thang = @sluggyThangs.findWhere({slug: slug}) + continue unless thang + gear[index] = thang.get('original') + # + if campaignLevel.allowedHeroes + for slug, index in campaignLevel.allowedHeroes + thang = @sluggyThangs.findWhere({slug: slug}) + continue unless thang + level.allowedHeroes[index] = thang.get('original') + #--------------- + + $.extend campaignLevel, _.omit(level.attributes, '_id') + achievements = @achievements.where {'related': levelOriginal} + rewards = [] + for achievement in achievements + for rewardType, rewardArray of achievement.get('rewards') + for reward in rewardArray + rewardObject = { achievement: achievement.id } + + if rewardType is 'heroes' + rewardObject.hero = reward + thangType = new ThangType({}, {project: thangTypeProject}) + thangType.setURL("/db/thang.type/#{reward}/version") + @supermodel.loadModel(thangType, 'thang') + + if rewardType is 'levels' + rewardObject.level = reward + if not @levels.findWhere({original: reward}) + level = new Level({}, {project: Campaign.denormalizedLevelProperties}) + level.setURL("/db/level/#{reward}/version") + @supermodel.loadModel(level, 'level') + + if rewardType is 'items' + rewardObject.item = reward + thangType = new ThangType({}, {project: thangTypeProject}) + thangType.setURL("/db/thang.type/#{reward}/version") + @supermodel.loadModel(thangType, 'thang') + + rewards.push rewardObject + campaignLevel.rewards = rewards + delete campaignLevel.unlocks + campaignLevels[levelOriginal] = campaignLevel + + @campaign.set('levels', campaignLevels) + + for level in _.values campaignLevels + model = @levels.findWhere {original: level.original} + model.set key, level[key] for key in Campaign.denormalizedLevelProperties +# @toSave.add model if model.hasLocalChanges() +# @updateRewardsForLevel model, level.rewards + + super() + + getRenderData: -> + c = super() + c.campaign = @campaign + c + + onClickSaveButton: -> + @toSave.set @toSave.filter (m) -> m.hasLocalChanges() + @openModalView new SaveCampaignModal({}, @toSave) + + afterRender: -> + super() + treemaOptions = + schema: Campaign.schema + data: $.extend({}, @campaign.attributes) + callbacks: + change: @onTreemaChanged + select: @onTreemaSelectionChanged + dblclick: @onTreemaDoubleClicked + nodeClasses: + levels: LevelsNode + level: LevelNode + campaigns: CampaignsNode + campaign: CampaignNode + achievement: AchievementNode + supermodel: @supermodel + + @treema = @$el.find('#campaign-treema').treema treemaOptions + @treema.build() + @treema.open() + @treema.childrenTreemas.levels?.open() + + @campaignView = new CampaignView({editorMode: true, supermodel: @supermodel}, @campaignHandle) + @campaignView.highlightElement = _.noop # make it stop + @listenTo @campaignView, 'level-moved', @onCampaignLevelMoved + @listenTo @campaignView, 'adjacent-campaign-moved', @onAdjacentCampaignMoved + @listenTo @campaignView, 'level-clicked', @onCampaignLevelClicked + @insertSubView @campaignView + + onTreemaChanged: (e, nodes) => + for node in nodes + path = node.getPath() + if _.string.startsWith path, '/levels/' + parts = path.split('/') + original = parts[2] + level = @supermodel.getModelByOriginal Level, original + campaignLevel = @treema.get "/levels/#{original}" + + @updateRewardsForLevel level, campaignLevel.rewards + + level.set key, campaignLevel[key] for key in Campaign.denormalizedLevelProperties + @toSave.add level if level.hasLocalChanges() + + @toSave.add @campaign + @campaign.set key, value for key, value of @treema.data + @campaignView.setCampaign(@campaign) + + onTreemaDoubleClicked: (e, node) => + path = node.getPath() + return unless _.string.startsWith path, '/levels/' + original = path.split('/')[2] + level = @supermodel.getModelByOriginal Level, original + @insertSubView new CampaignLevelView({}, level) + + onCampaignLevelMoved: (e) -> + path = "levels/#{e.levelOriginal}/position" + @treema.set path, e.position + + onAdjacentCampaignMoved: (e) -> + path = "adjacentCampaigns/#{e.campaignID}/position" + @treema.set path, e.position + + onCampaignLevelClicked: (levelOriginal) -> + return unless levelTreema = @treema.childrenTreemas?.levels?.childrenTreemas?[levelOriginal] + levelTreema.select() +# levelTreema.open() + + updateRewardsForLevel: (level, rewards) -> + achievements = @supermodel.getModels(Achievement) + achievements = (a for a in achievements when a.get('related') is level.get('original')) + for achievement in achievements + rewardSubset = (r for r in rewards when r.achievement is achievement.id) + oldRewards = achievement.get 'rewards' + newRewards = {} + + heroes = _.compact((r.hero for r in rewardSubset)) + newRewards.heroes = heroes if heroes.length + + items = _.compact((r.item for r in rewardSubset)) + newRewards.items = items if items.length + + levels = _.compact((r.level for r in rewardSubset)) + newRewards.levels = levels if levels.length + + newRewards.gems = oldRewards.gems if oldRewards.gems + achievement.set 'rewards', newRewards + if achievement.hasLocalChanges() + @toSave.add achievement + +class LevelsNode extends TreemaObjectNode + valueClass: 'treema-levels' + @levels: {} + + buildValueForDisplay: (valEl, data) -> + @buildValueForDisplaySimply valEl, ''+_.size(data) + + childPropertiesAvailable: -> @childSource + + childSource: (req, res) => + s = new Backbone.Collection([], {model:Level}) + s.url = '/db/level' + s.fetch({data: {term:req.term, project: Campaign.denormalizedLevelProperties.join(',')}}) + s.once 'sync', (collection) => + for level in collection.models + LevelsNode.levels[level.get('original')] = level + @settings.supermodel.registerModel level + mapped = ({label: r.get('name'), value: r.get('original')} for r in collection.models) + res(mapped) + + +class LevelNode extends TreemaObjectNode + valueClass: 'treema-level' + buildValueForDisplay: (valEl, data) -> + @buildValueForDisplaySimply valEl, data.name + + populateData: -> + return if @data.name? + data = _.pick LevelsNode.levels[@keyForParent].attributes, Campaign.denormalizedLevelProperties + _.extend @data, data + +class CampaignsNode extends TreemaObjectNode + valueClass: 'treema-campaigns' + @campaigns: {} + + buildValueForDisplay: (valEl, data) -> + @buildValueForDisplaySimply valEl, ''+_.size(data) + + childPropertiesAvailable: -> @childSource + + childSource: (req, res) => + s = new Backbone.Collection([], {model:Campaign}) + s.url = '/db/campaign' + s.fetch({data: {term:req.term, project: campaign.denormalizedCampaignProperties}}) + s.once 'sync', (collection) -> + CampaignsNode.campaigns[campaign.id] = campaign for campaign in collection.models + mapped = ({label: r.get('name'), value: r.id} for r in collection.models) + res(mapped) + + +class CampaignNode extends TreemaObjectNode + valueClass: 'treema-campaign' + buildValueForDisplay: (valEl, data) -> + @buildValueForDisplaySimply valEl, data.name + + populateData: -> + return if @data.name? + data = _.pick CampaignsNode.campaigns[@keyForParent].attributes, Campaign.denormalizedCampaignProperties + _.extend @data, data + +class AchievementNode extends treemaExt.IDReferenceNode + buildSearchURL: (term) -> "#{@url}?term=#{term}&project=#{achievementProject.join(',')}" + + + + +#campaign = { +# name: 'Dungeon' +# levels: {} +#} +# +# +#levels = [ +# { +# name: 'Dungeons of Kithgard' +# type: 'hero' +# id: 'dungeons-of-kithgard' +# original: '5411cb3769152f1707be029c' +# description: 'Grab the gem, but touch nothing else. Start here.' +# x: 14 +# y: 15.5 +# nextLevels: +# continue: 'gems-in-the-deep' +# } +# { +# name: 'Gems in the Deep' +# type: 'hero' +# id: 'gems-in-the-deep' +# original: '54173c90844506ae0195a0b4' +# description: 'Quickly collect the gems; you will need them.' +# x: 29 +# y: 12 +# nextLevels: +# continue: 'shadow-guard' +# } +# { +# name: 'Shadow Guard' +# type: 'hero' +# id: 'shadow-guard' +# original: '54174347844506ae0195a0b8' +# description: 'Evade the Kithgard minion.' +# x: 40.54 +# y: 11.03 +# nextLevels: +# continue: 'forgetful-gemsmith' +# } +# { +# name: 'Kounter Kithwise' +# type: 'hero' +# id: 'kounter-kithwise' +# original: '54527a6257e83800009730c7' +# description: 'Practice your evasion skills with more guards.' +# x: 35.37 +# y: 20.61 +# nextLevels: +# continue: 'crawlways-of-kithgard' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'Crawlways of Kithgard' +# type: 'hero' +# id: 'crawlways-of-kithgard' +# original: '545287ef57e83800009730d5' +# description: 'Dart in and grab the gem–at the right moment.' +# x: 36.48 +# y: 29.03 +# nextLevels: +# continue: 'forgetful-gemsmith' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'Forgetful Gemsmith' +# type: 'hero' +# id: 'forgetful-gemsmith' +# original: '544a98f62d002f0000fe331a' +# description: 'Grab even more gems as you practice moving.' +# x: 54.98 +# y: 10.53 +# nextLevels: +# continue: 'true-names' +# } +# { +# name: 'True Names' +# type: 'hero' +# id: 'true-names' +# original: '541875da4c16460000ab990f' +# description: 'Learn an enemy\'s true name to defeat it.' +# x: 68.44 +# y: 10.70 +# nextLevels: +# continue: 'the-raised-sword' +# unlocksHero: { +# img: '/file/db/thang.type/53e12be0d042f23505c3023b/portrait.png' +# originalID: '53e12be0d042f23505c3023b' +# } +# } +# { +# name: 'Favorable Odds' +# type: 'hero' +# id: 'favorable-odds' +# original: '5452972f57e83800009730de' +# description: 'Test out your battle skills by defeating more munchkins.' +# x: 88.25 +# y: 14.92 +# nextLevels: +# continue: 'the-raised-sword' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'The Raised Sword' +# type: 'hero' +# id: 'the-raised-sword' +# original: '5418aec24c16460000ab9aa6' +# description: 'Learn to equip yourself for combat.' +# x: 81.51 +# y: 17.92 +# nextLevels: +# continue: 'haunted-kithmaze' +# } +# { +# name: 'Haunted Kithmaze' +# type: 'hero' +# id: 'haunted-kithmaze' +# original: '545a5914d820eb0000f6dc0a' +# description: 'The builders of Kithgard constructed many mazes to confuse travelers.' +# x: 78 +# y: 29 +# nextLevels: +# continue: 'the-second-kithmaze' +# } +# { +# name: 'Riddling Kithmaze' +# type: 'hero' +# id: 'riddling-kithmaze' +# original: '5418b9d64c16460000ab9ab4' +# description: 'If at first you go astray, change your loop to find the way.' +# x: 69.97 +# y: 28.03 +# nextLevels: +# continue: 'descending-further' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'Descending Further' +# type: 'hero' +# id: 'descending-further' +# original: '5452a84d57e83800009730e4' +# description: 'Another day, another maze.' +# x: 61.68 +# y: 22.80 +# nextLevels: +# continue: 'the-second-kithmaze' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'The Second Kithmaze' +# type: 'hero' +# id: 'the-second-kithmaze' +# original: '5418cf256bae62f707c7e1c3' +# description: 'Many have tried, few have found their way through this maze.' +# x: 54.49 +# y: 26.49 +# nextLevels: +# continue: 'dread-door' +# } +# { +# name: 'Dread Door' +# type: 'hero' +# id: 'dread-door' +# original: '5418d40f4c16460000ab9ac2' +# description: 'Behind a dread door lies a chest full of riches.' +# x: 60.52 +# y: 33.70 +# nextLevels: +# continue: 'known-enemy' +# } +# { +# name: 'Known Enemy' +# type: 'hero' +# id: 'known-enemy' +# original: '5452adea57e83800009730ee' +# description: 'Begin to use variables in your battles.' +# x: 67 +# y: 39 +# nextLevels: +# continue: 'master-of-names' +# } +# { +# name: 'Master of Names' +# type: 'hero' +# id: 'master-of-names' +# original: '5452c3ce57e83800009730f7' +# description: 'Use your glasses to defend yourself from the Kithmen.' +# x: 75 +# y: 46 +# nextLevels: +# continue: 'lowly-kithmen' +# } +# { +# name: 'Lowly Kithmen' +# type: 'hero' +# id: 'lowly-kithmen' +# original: '541b24511ccc8eaae19f3c1f' +# description: 'Now that you can see them, they\'re everywhere!' +# x: 85 +# y: 40 +# nextLevels: +# continue: 'closing-the-distance' +# } +# { +# name: 'Closing the Distance' +# type: 'hero' +# id: 'closing-the-distance' +# original: '541b288e1ccc8eaae19f3c25' +# description: 'Kithmen are not the only ones to stand in your way.' +# x: 93 +# y: 47 +# nextLevels: +# continue: 'the-final-kithmaze' +# } +# { +# name: 'Tactical Strike' +# type: 'hero' +# id: 'tactical-strike' +# original: '5452cfa706a59e000067e4f5' +# description: 'They\'re, uh, coming right for us! Sneak up behind them.' +# x: 83.23 +# y: 52.73 +# nextLevels: +# continue: 'the-final-kithmaze' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'The Final Kithmaze' +# type: 'hero' +# id: 'the-final-kithmaze' +# original: '541b434e1ccc8eaae19f3c33' +# description: 'To escape you must find your way through an Elder Kithman\'s maze.' +# x: 86.95 +# y: 64.70 +# nextLevels: +# continue: 'kithgard-gates' +# } +# { +# name: 'The Gauntlet' +# type: 'hero' +# id: 'the-gauntlet' +# original: '5452d8b906a59e000067e4fa' +# description: 'Rush for the stairs, battling foes at every turn.' +# x: 76.50 +# y: 72.69 +# nextLevels: +# continue: 'kithgard-gates' +# practice: true +# requiresSubscription: true +# } +# { +# name: 'Kithgard Gates' +# type: 'hero' +# id: 'kithgard-gates' +# original: '541c9a30c6362edfb0f34479' +# description: 'Escape the Kithgard dungeons and don\'t let the guardians get you.' +# x: 89 +# y: 82 +# nextLevels: +# continue: 'defense-of-plainswood' +# } +# { +# name: 'Cavern Survival' +# type: 'hero-ladder' +# id: 'cavern-survival' +# original: '544437e0645c0c0000c3291d' +# description: 'Stay alive longer than your opponent amidst hordes of ogres!' +# x: 17.54 +# y: 78.39 +# } +#] +# +#options = +# 'dungeons-of-kithgard': +# disableSpaces: true +# hidesSubmitUntilRun: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots'} +# requiredCode: ['moveRight'] +# 'gems-in-the-deep': +# disableSpaces: true +# hidesSubmitUntilRun: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots'} +# 'shadow-guard': +# disableSpaces: true +# hidesSubmitUntilRun: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword'} +# 'kounter-kithwise': +# disableSpaces: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'} +# 'crawlways-of-kithgard': +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'} +# 'forgetful-gemsmith': +# disableSpaces: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots'} +# restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'} +# 'true-names': +# disableSpaces: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', waist: 'leather-belt'} +# restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'} +# requiredCode: ['Brak'] +# 'favorable-odds': +# disableSpaces: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword'} +# restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'} +# 'the-raised-sword': +# disableSpaces: true +# hidesPlayButton: true +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {feet: 'leather-boots', 'programming-book': 'programmaticon-i'} +# 'the-first-kithmaze': +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'} +# restrictedGear: {feet: 'leather-boots'} +# requiredCode: ['loop'] +# 'haunted-kithmaze': +# hidesRunShortcut: true +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# moveRightLoopSnippet: true +# requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'} +# restrictedGear: {feet: 'leather-boots'} +# requiredCode: ['loop'] +# 'descending-further': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'} +# restrictedGear: {feet: 'leather-boots'} +# 'the-second-kithmaze': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# moveRightLoopSnippet: true +# requiredGear: {feet: 'simple-boots', 'programming-book': 'programmaticon-i'} +# restrictedGear: {feet: 'leather-boots'} +# 'dread-door': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'} +# restrictedGear: {feet: 'leather-boots'} +# 'known-enemy': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {feet: 'leather-boots'} +# suspectCode: [{name: 'enemy-in-quotes', pattern: '[\'"]enemy'}] # ' +# 'master-of-names': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {feet: 'leather-boots'} +# requiredCode: ['findNearestEnemy'] +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'lowly-kithmen': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {feet: 'leather-boots'} +# requiredCode: ['findNearestEnemy'] +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'closing-the-distance': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'leather-boots'} +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'tactical-strike': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'leather-boots'} +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'the-final-kithmaze': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'the-gauntlet': +# hidesHUD: true +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', torso: 'tarnished-bronze-breastplate', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer'} +# suspectCode: [{name: 'lone-find-nearest-enemy', pattern: '^[ ]*(self|this|@)?[:.]?findNearestEnemy()'}] +# 'kithgard-gates': +# hidesSay: true +# hidesCodeToolbar: true +# hidesRealTimePlayback: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {'right-hand': 'simple-sword'} +# 'defense-of-plainswood': +# hidesRealTimePlayback: true +# hidesCodeToolbar: true +# requiredGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer'} +# restrictedGear: {'right-hand': 'simple-sword'} +# 'winding-trail': +# hidesRealTimePlayback: true +# hidesCodeToolbar: true +# requiredGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword'} +# 'patrol-buster': +# hidesRealTimePlayback: true +# hidesCodeToolbar: true +# requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'} +# 'endangered-burl': +# hidesRealTimePlayback: true +# hidesCodeToolbar: true +# requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'} +# 'village-guard': +# hidesCodeToolbar: true +# lockDefaultCode: true +# requiredGear: {feet: 'leather-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'} +# 'thornbush-farm': +# hidesCodeToolbar: true +# lockDefaultCode: true +# requiredGear: {feet: 'leather-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'} +# requiredCode: ['topEnemy'] +# 'back-to-back': +# hidesCodeToolbar: true +# requiredGear: {feet: 'leather-boots', torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'simple-sword', 'left-hand': 'wooden-shield'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'} +# 'ogre-encampment': +# requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'simple-sword', 'left-hand': 'wooden-shield'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i'} +# 'woodland-cleaver': +# requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'long-sword', 'left-hand': 'wooden-shield', wrists: 'sundial-wristwatch', feet: 'leather-boots'} +# restrictedGear: {feet: 'simple-boots', 'right-hand': 'simple-sword', 'programming-book': 'programmaticon-i'} +# 'shield-rush': +# requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'crude-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'} +# restrictedGear: {'left-hand': 'wooden-shield', 'programming-book': 'programmaticon-i'} +# +## Warrior branch +# 'peasant-protection': +# requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'} +# restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'} +# 'munchkin-swarm': +# requiredGear: {torso: 'tarnished-bronze-breastplate', waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'} +# restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# +## Ranger branch +# 'munchkin-harvest': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'} +# restrictedGear: {'programming-book': 'programmaticon-i'} +# allowedHeroes: ['captain', 'knight', 'samurai'] +# 'swift-dagger': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'crude-crossbow', 'left-hand': 'crude-dagger', wrists: 'sundial-wristwatch'} +# restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'} +# allowedHeroes: ['ninja', 'trapper', 'forest-archer'] +# 'shrapnel': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'crude-crossbow', 'left-hand': 'weak-charge', wrists: 'sundial-wristwatch'} +# restrictedGear: {eyes: 'crude-glasses', 'left-hand': 'crude-dagger', 'programming-book': 'programmaticon-i'} +# allowedHeroes: ['ninja', 'trapper', 'forest-archer'] +# +## Wizard branch +# 'arcane-ally': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield', wrists: 'sundial-wristwatch'} +# restrictedGear: {eyes: 'crude-glasses', 'programming-book': 'programmaticon-i'} +# allowedHeroes: ['captain', 'knight', 'samurai'] +# 'touch-of-death': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'enchanted-stick', 'left-hand': 'unholy-tome-i', wrists: 'sundial-wristwatch'} +# restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# allowedHeroes: ['librarian', 'potion-master', 'sorcerer'] +# 'bonemender': +# requiredGear: {waist: 'leather-belt', 'programming-book': 'programmaticon-ii', eyes: 'wooden-glasses', 'right-hand': 'enchanted-stick', 'left-hand': 'book-of-life-i', wrists: 'sundial-wristwatch'} +# restrictedGear: {'left-hand': 'unholy-tome-i', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# requiredCode: ['canCast'] +# allowedHeroes: ['librarian', 'potion-master', 'sorcerer'] +# +# 'coinucopia': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags'} +# restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'copper-meadows': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses'} +# restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'drop-the-flag': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', 'right-hand': 'crude-builders-hammer'} +# restrictedGear: {'right-hand': 'long-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'deadly-pursuit': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', 'right-hand': 'crude-builders-hammer'} +# restrictedGear: {'right-hand': 'long-sword', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'rich-forager': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', torso: 'tarnished-bronze-breastplate', 'right-hand': 'long-sword', 'left-hand': 'bronze-shield'} +# restrictedGear: {'right-hand': 'crude-builders-hammer', 'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'multiplayer-treasure-grove': +# requiredGear: {'programming-book': 'programmaticon-ii', feet: 'leather-boots', flag: 'basic-flags', eyes: 'wooden-glasses', torso: 'tarnished-bronze-breastplate'} +# restrictedGear: {'programming-book': 'programmaticon-i', eyes: 'crude-glasses'} +# 'siege-of-stonehold': +# requiredGear: {} +# restrictedGear: {} +# +## Desert +# 'the-dunes': +# requiredGear: {} +# restrictedGear: {} +# 'the-mighty-sand-yak': +# requiredGear: {} +# restrictedGear: {} +# 'oasis': +# requiredGear: {} +# restrictedGear: {} diff --git a/app/views/editor/campaign/CampaignLevelView.coffee b/app/views/editor/campaign/CampaignLevelView.coffee new file mode 100644 index 000000000..b27c02aeb --- /dev/null +++ b/app/views/editor/campaign/CampaignLevelView.coffee @@ -0,0 +1,19 @@ +CocoView = require 'views/core/CocoView' + +module.exports = class CampaignLevelView extends CocoView + id: 'campaign-level-view' + template: require 'templates/editor/campaign/campaign-level-view' + + events: + 'click .close': 'onClickClose' + + constructor: (options, @level) -> + super(options) + + getRenderData: -> + c = super() + c.level = @level + c + + onClickClose: -> + @$el.addClass('hidden') \ No newline at end of file diff --git a/app/views/editor/campaign/SaveCampaignModal.coffee b/app/views/editor/campaign/SaveCampaignModal.coffee new file mode 100644 index 000000000..69bee3904 --- /dev/null +++ b/app/views/editor/campaign/SaveCampaignModal.coffee @@ -0,0 +1,34 @@ +ModalView = require 'views/core/ModalView' +template = require 'templates/editor/campaign/save-campaign-modal' +DeltaView = require 'views/editor/DeltaView' + +module.exports = class SaveCampaignModal extends ModalView + id: 'save-campaign-modal' + template: template + plain: true + + events: + 'click #save-button': 'onClickSaveButton' + + constructor: (options, @modelsToSave) -> + super(options) + + getRenderData: -> + c = super() + c.modelsToSave = @modelsToSave + c + + afterRender: -> + @$el.find('.delta-view').each((i, el) => + $el = $(el) + model = @modelsToSave.find( id: $el.data('model-id')) + deltaView = new DeltaView({model: model}) + @insertSubView(deltaView, $el) + ) + super() + + onClickSaveButton: -> + @showLoading() + modelsBeingSaved = (model.patch() for model in @modelsToSave.models) + modelsBeingSaved = modelsBeingSaved + $.when(_.compact(modelsBeingSaved)...).done(-> document.location.reload()) \ No newline at end of file diff --git a/app/views/play/CampaignView.coffee b/app/views/play/CampaignView.coffee new file mode 100644 index 000000000..16d829556 --- /dev/null +++ b/app/views/play/CampaignView.coffee @@ -0,0 +1,384 @@ +RootView = require 'views/core/RootView' +template = require 'templates/play/campaign-view' +LevelSession = require 'models/LevelSession' +EarnedAchievement = require 'models/EarnedAchievement' +CocoCollection = require 'collections/CocoCollection' +Campaign = require 'models/Campaign' +AudioPlayer = require 'lib/AudioPlayer' +LevelSetupManager = require 'lib/LevelSetupManager' +ThangType = require 'models/ThangType' +MusicPlayer = require 'lib/surface/MusicPlayer' +storage = require 'core/storage' +AuthModal = require 'views/core/AuthModal' +SubscribeModal = require 'views/core/SubscribeModal' +Level = require 'models/Level' +utils = require 'core/utils' + +trackedHourOfCode = false + +class LevelSessionsCollection extends CocoCollection + url: '' + model: LevelSession + + constructor: (model) -> + super() + @url = "/db/user/#{me.id}/level.sessions?project=state.complete,levelID" + +module.exports = class WorldMapView extends RootView + id: 'campaign-view' + template: template + + subscriptions: + 'subscribe-modal:subscribed': 'onSubscribed' + + events: + 'click .map-background': 'onClickMap' + 'click .level a': 'onClickLevel' + 'click .level-info-container .start-level': 'onClickStartLevel' + 'mouseenter .level a': 'onMouseEnterLevel' + 'mouseleave .level a': 'onMouseLeaveLevel' + 'mousemove .map': 'onMouseMoveMap' + 'click #volume-button': 'onToggleVolume' + + constructor: (options, @terrain='dungeon') -> + if options and application.isIPAdApp # TODO: later only clear the SuperModel if it has received a memory warning (not in app store yet) + options.supermodel = null + super options + options ?= {} + + @campaign = new Campaign({_id:@terrain}) + @campaign = @supermodel.loadModel(@campaign, 'campaign').model + + @editorMode = options.editorMode + @nextLevel = @getQueryVariable 'next' + @levelStatusMap = {} + @levelPlayCountMap = {} + @sessions = @supermodel.loadCollection(new LevelSessionsCollection(), 'your_sessions', null, 0).model + + # Temporary attempt to make sure all earned rewards are accounted for. Figure out a better solution... + @earnedAchievements = new CocoCollection([], {url: '/db/earned_achievement', model:EarnedAchievement, project: ['earnedRewards']}) + @listenToOnce @earnedAchievements, 'sync', -> + earned = me.get('earned') + for m in @earnedAchievements.models + continue unless loadedEarned = m.get('earnedRewards') + for group in ['heroes', 'levels', 'items'] + continue unless loadedEarned[group] + for reward in loadedEarned[group] + if reward not in earned[group] + console.warn 'Filling in a gap for reward', group, reward + earned[group].push(reward) + + @supermodel.loadCollection(@earnedAchievements, 'achievements') + + @listenToOnce @sessions, 'sync', @onSessionsLoaded + @getLevelPlayCounts() + $(window).on 'resize', @onWindowResize + @playAmbientSound() + @probablyCachedMusic = storage.load("loaded-menu-music") + musicDelay = if @probablyCachedMusic then 1000 else 10000 + @playMusicTimeout = _.delay (=> @playMusic() unless @destroyed), musicDelay + @hadEverChosenHero = me.get('heroConfig')?.thangType + @listenTo me, 'change:purchased', -> @renderSelectors('#gems-count') + @listenTo me, 'change:spent', -> @renderSelectors('#gems-count') + @listenTo me, 'change:heroConfig', -> @updateHero() + window.tracker?.trackEvent 'Loaded World Map', category: 'World Map', ['Google Analytics'] + + # If it's a new player who didn't appear to come from Hour of Code, we register her here without setting the hourOfCode property. + elapsed = (new Date() - new Date(me.get('dateCreated'))) + if not trackedHourOfCode and not me.get('hourOfCode') and elapsed < 5 * 60 * 1000 + $('body').append($('<img src="http://code.org/api/hour/begin_codecombat.png" style="visibility: hidden;">')) + trackedHourOfCode = true + + @requiresSubscription = not me.isPremium() + + destroy: -> + @setupManager?.destroy() + @$el.find('.ui-draggable').draggable 'destroy' + $(window).off 'resize', @onWindowResize + if ambientSound = @ambientSound + # Doesn't seem to work; stops immediately. + createjs.Tween.get(ambientSound).to({volume: 0.0}, 1500).call -> ambientSound.stop() + @musicPlayer?.destroy() + clearTimeout @playMusicTimeout + super() + + getLevelPlayCounts: -> + return # TODO: Either use the campaign object instead of hardcoded data or get the data some other way + return unless me.isAdmin() + success = (levelPlayCounts) => + return if @destroyed + for level in levelPlayCounts + @levelPlayCountMap[level._id] = playtime: level.playtime, sessions: level.sessions + @render() if @fullyRendered + + levelIDs = [] + for campaign in campaigns + for level in campaign.levels + levelIDs.push level.id + levelPlayCountsRequest = @supermodel.addRequestResource 'play_counts', { + url: '/db/level/-/play_counts' + data: {ids: levelIDs} + method: 'POST' + success: success + }, 0 + levelPlayCountsRequest.load() + + onLoaded: -> + return if @fullyRendered + @fullyRendered = true + @render() + @preloadTopHeroes() unless me.get('heroConfig')?.thangType + + setCampaign: (@campaign) -> + @render() + + onSubscribed: -> + @requiresSubscription = false + @render() + + getRenderData: (context={}) -> + context = super(context) + context.campaign = @campaign + context.levels = _.values($.extend true, {}, @campaign.get('levels')) + for level in context.levels + level.position ?= { x: 10, y: 10 } + level.locked = not me.ownsLevel level.original + window.levelUnlocksNotWorking = true if level.locked and level.id is @nextLevel # Temporary + level.locked = false if window.levelUnlocksNotWorking # Temporary; also possible in HeroVictoryModal + level.locked = false if @levelStatusMap[level.id] in ['started', 'complete'] + level.locked = false if me.get('slug') is 'nick' + level.locked = false if @editorMode + level.disabled = false if @levelStatusMap[level.id] in ['started', 'complete'] + level.color = 'rgb(255, 80, 60)' + if level.requiresSubscription + level.color = 'rgb(80, 130, 200)' + if level.unlocksHero + level.unlockedHero = level.unlocksHero.originalID in (me.get('earned')?.heroes or []) + level.hidden = level.locked or level.disabled + + # put lower levels in last, so in the world map they layer over one another properly. + context.campaign.levels = (_.sortBy context.campaign.levels, (l) -> l.position.y).reverse() + + context.levelStatusMap = @levelStatusMap + context.levelPlayCountMap = @levelPlayCountMap + context.isIPadApp = application.isIPadApp + context.mapType = _.string.slugify @terrain + context.nextLevel = @nextLevel + context.forestIsAvailable = Level.levels['defense-of-plainswood'] in (me.get('earned')?.levels or []) + context.desertIsAvailable = Level.levels['the-mighty-sand-yak'] in (me.get('earned')?.levels or []) + context.requiresSubscription = @requiresSubscription + context.editorMode = @editorMode + context.adjacentCampaigns = _.filter _.values(_.cloneDeep(@campaign.get('adjacentCampaigns') or {})), (ac) -> + return false if ac.showIfUnlocked and ac.showIfUnlocked not in (me.get('unlocked')?.levels or []) + ac.name = utils.i18n ac, 'name' + ac.description = utils.i18n ac, 'description' + styles = [] + styles.push "color: #{ac.color}" if ac.color + styles.push "transform: rotate(#{ac.rotation}deg)" if ac.rotation + ac.position ?= { x: 10, y: 10 } + styles.push "left: #{ac.position.x}%" + styles.push "top: #{ac.position.y}%" + ac.style = styles.join('; ') + return true + context + + afterRender: -> + super() + @onWindowResize() + unless application.isIPadApp + _.defer => @$el?.find('.game-controls .btn').tooltip() # Have to defer or i18n doesn't take effect. + view = @ + @$el.find('.level, .campaign-switch').tooltip().each -> + return unless me.isAdmin() + $(@).draggable().on 'dragstop', -> + bg = $('.map-background') + x = ($(@).offset().left - bg.offset().left + $(@).outerWidth() / 2) / bg.width() + y = 1 - ($(@).offset().top - bg.offset().top + $(@).outerHeight() / 2) / bg.height() + e = { position: { x: (100 * x), y: (100 * y) }, levelOriginal: $(@).data('level-id'), campaignID: $(@).data('campaign-id') } + view.trigger 'level-moved', e if e.levelOriginal + view.trigger 'adjacent-campaign-moved', e if e.campaignID + @$el.addClass _.string.slugify @terrain + @updateVolume() + @updateHero() + unless window.currentModal or not @fullyRendered + @highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top'] + if levelID = @$el.find('.level.next').data('level-id') + @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() unless @editorMode + pos = @$el.find('.level.next').offset() + @adjustLevelInfoPosition pageX: pos.left, pageY: pos.top + @manuallyPositionedLevelInfoID = levelID + + afterInsert: -> + super() + return unless @getQueryVariable 'signup' + return if me.get('email') + @endHighlight() + authModal = new AuthModal supermodel: @supermodel + authModal.mode = 'signup' + @openModalView authModal + + onSessionsLoaded: (e) -> + return if @editorMode + for session in @sessions.models + @levelStatusMap[session.get('levelID')] = if session.get('state')?.complete then 'complete' else 'started' + if @nextLevel and @levelStatusMap[@nextLevel] is 'complete' + @nextLevel = null + @render() + + onClickMap: (e) -> + @$levelInfo?.hide() + # Easy-ish way of figuring out coordinates for placing level dots. + x = e.offsetX / @$el.find('.map-background').width() + y = (1 - e.offsetY / @$el.find('.map-background').height()) + console.log " x: #{(100 * x).toFixed(2)}\n y: #{(100 * y).toFixed(2)}\n" + + onClickLevel: (e) -> + e.preventDefault() + e.stopPropagation() + @$levelInfo?.hide() + levelElement = $(e.target).parents('.level') + levelID = levelElement.data('level-id') + if @editorMode + return @trigger 'level-clicked', levelID + level = _.find _.values(@campaign.get('levels')), id: levelID + if application.isIPadApp + @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() + @adjustLevelInfoPosition e + @endHighlight() + else + if level.requiresSubscription and @requiresSubscription and not @levelStatusMap[level.id] and not level.adventurer + @openModalView new SubscribeModal() + window.tracker?.trackEvent 'Show subscription modal', category: 'Subscription', label: 'map level clicked', level: levelID + else if $(e.target).attr('disabled') + Backbone.Mediator.publish 'router:navigate', route: '/contribute/adventurer' + return + else if $(e.target).parent().hasClass 'locked' + return + else + @startLevel levelElement + window.tracker?.trackEvent 'Clicked Level', category: 'World Map', levelID: levelID, ['Google Analytics'] + + onClickStartLevel: (e) -> + levelElement = $(e.target).parents('.level-info-container') + @startLevel levelElement + window.tracker?.trackEvent 'Clicked Start Level', category: 'World Map', levelID: levelElement.data('level-id'), ['Google Analytics'] + + startLevel: (levelElement) -> + @setupManager?.destroy() + @setupManager = new LevelSetupManager supermodel: @supermodel, levelID: levelElement.data('level-id'), levelPath: levelElement.data('level-path'), levelName: levelElement.data('level-name'), hadEverChosenHero: @hadEverChosenHero, parent: @ + @setupManager.open() + @$levelInfo?.hide() + + onMouseEnterLevel: (e) -> + return if application.isIPadApp + return if @editorMode + levelID = $(e.target).parents('.level').data('level-id') + return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID + @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() + @adjustLevelInfoPosition e + @endHighlight() + @manuallyPositionedLevelInfoID = false + + onMouseLeaveLevel: (e) -> + return if application.isIPadApp + levelID = $(e.target).parents('.level').data('level-id') + return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID + @$el.find(".level-info-container[data-level-id='#{levelID}']").hide() + @manuallyPositionedLevelInfoID = null + @$levelInfo = null + + onMouseMoveMap: (e) -> + return if application.isIPadApp + @adjustLevelInfoPosition e unless @manuallyPositionedLevelInfoID + + adjustLevelInfoPosition: (e) -> + return unless @$levelInfo + @$map ?= @$el.find('.map') + mapOffset = @$map.offset() + mapX = e.pageX - mapOffset.left + mapY = e.pageY - mapOffset.top + margin = 20 + width = @$levelInfo.outerWidth() + @$levelInfo.css('left', Math.min(Math.max(margin, mapX - width / 2), @$map.width() - width - margin)) + height = @$levelInfo.outerHeight() + top = mapY - @$levelInfo.outerHeight() - 60 + if top < 20 + top = mapY + 60 + @$levelInfo.css('top', top) + + onWindowResize: (e) => + mapHeight = iPadHeight = 1536 + mapWidth = {dungeon: 2350, forest: 2500, desert: 2350}[@terrain] or 2350 + aspectRatio = mapWidth / mapHeight + pageWidth = @$el.width() + pageHeight = @$el.height() + widthRatio = pageWidth / mapWidth + heightRatio = pageHeight / mapHeight + # Make sure we can see the whole map, fading to background in one dimension. + if heightRatio <= widthRatio + # Left and right margin + resultingHeight = pageHeight + resultingWidth = resultingHeight * aspectRatio + else + # Top and bottom margin + resultingWidth = pageWidth + resultingHeight = resultingWidth / aspectRatio + resultingMarginX = (pageWidth - resultingWidth) / 2 + resultingMarginY = (pageHeight - resultingHeight) / 2 + @$el.find('.map').css(width: resultingWidth, height: resultingHeight, 'margin-left': resultingMarginX, 'margin-top': resultingMarginY) + + playAmbientSound: -> + return if @ambientSound + return unless file = {dungeon: 'ambient-dungeon', forest: 'ambient-map-grass', desert: 'ambient-desert'}[@terrain] + src = "/file/interface/#{file}#{AudioPlayer.ext}" + unless AudioPlayer.getStatus(src)?.loaded + AudioPlayer.preloadSound src + Backbone.Mediator.subscribeOnce 'audio-player:loaded', @playAmbientSound, @ + return + @ambientSound = createjs.Sound.play src, loop: -1, volume: 0.1 + createjs.Tween.get(@ambientSound).to({volume: 0.5}, 1000) + + playMusic: -> + @musicPlayer = new MusicPlayer() + musicFile = '/music/music-menu' + Backbone.Mediator.publish 'music-player:play-music', play: true, file: musicFile + storage.save("loaded-menu-music", true) unless @probablyCachedMusic + + preloadTopHeroes: -> + for heroID in ['captain', 'knight'] + url = "/db/thang.type/#{ThangType.heroes[heroID]}/version" + continue if @supermodel.getModel url + fullHero = new ThangType() + fullHero.setURL url + @supermodel.loadModel fullHero, 'thang' + + updateVolume: (volume) -> + volume ?= me.get('volume') ? 1.0 + classes = ['vol-off', 'vol-down', 'vol-up'] + button = $('#volume-button', @$el) + button.toggleClass 'vol-off', volume <= 0.0 + button.toggleClass 'vol-down', 0.0 < volume < 1.0 + button.toggleClass 'vol-up', volume >= 1.0 + createjs.Sound.setVolume(if volume is 1 then 0.6 else volume) # Quieter for now until individual sound FX controls work again. + if volume isnt me.get 'volume' + me.set 'volume', volume + me.patch() + + onToggleVolume: (e) -> + button = $(e.target).closest('#volume-button') + classes = ['vol-off', 'vol-down', 'vol-up'] + volumes = [0, 0.4, 1.0] + for oldClass, i in classes + if button.hasClass oldClass + newI = (i + 1) % classes.length + break + else if i is classes.length - 1 # no oldClass + newI = 2 + @updateVolume volumes[newI] + + updateHero: -> + return unless hero = me.get('heroConfig')?.thangType + for slug, original of ThangType.heroes when original is hero + @$el.find('.player-hero-icon').removeClass().addClass('player-hero-icon ' + slug) + return + console.error "WorldMapView hero update couldn't find hero slug for original:", hero diff --git a/app/views/play/WorldMapView.coffee b/app/views/play/WorldMapView.coffee index 9ba973c00..b5095162d 100644 --- a/app/views/play/WorldMapView.coffee +++ b/app/views/play/WorldMapView.coffee @@ -43,6 +43,8 @@ module.exports = class WorldMapView extends RootView options.supermodel = null @terrain ?= 'dungeon' # or 'forest', 'desert' super options + options ?= {} + @editorMode = options.editorMode @nextLevel = @getQueryVariable 'next' @levelStatusMap = {} @levelPlayCountMap = {} @@ -97,6 +99,7 @@ module.exports = class WorldMapView extends RootView super() getLevelPlayCounts: -> + return return unless me.isAdmin() success = (levelPlayCounts) => return if @destroyed @@ -137,12 +140,12 @@ module.exports = class WorldMapView extends RootView level.locked = false if window.levelUnlocksNotWorking # Temporary; also possible in HeroVictoryModal level.locked = false if @levelStatusMap[level.id] in ['started', 'complete'] level.locked = false if me.get('slug') is 'nick' + level.locked = false if @editorMode level.disabled = false if @levelStatusMap[level.id] in ['started', 'complete'] level.color = 'rgb(255, 80, 60)' if level.requiresSubscription level.color = 'rgb(80, 130, 200)' if level.unlocksHero - level.color = 'rgb(0,0,0)' level.unlockedHero = level.unlocksHero.originalID in (me.get('earned')?.heroes or []) level.hidden = level.locked or level.disabled @@ -159,6 +162,7 @@ module.exports = class WorldMapView extends RootView context.forestIsAvailable = @startedForestLevel or (Level.levels['defense-of-plainswood'] in (me.get('earned')?.levels or [])) context.desertIsAvailable = @startedDesertLevel or (Level.levels['the-mighty-sand-yak'] in (me.get('earned')?.levels or [])) context.requiresSubscription = @requiresSubscription + context.editorMode = @editorMode context afterRender: -> @@ -179,7 +183,7 @@ module.exports = class WorldMapView extends RootView unless window.currentModal or not @fullyRendered @highlightElement '.level.next', delay: 500, duration: 60000, rotation: 0, sides: ['top'] if levelID = @$el.find('.level.next').data('level-id') - @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() + @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() unless @editorMode pos = @$el.find('.level.next').offset() @adjustLevelInfoPosition pageX: pos.left, pageY: pos.top @manuallyPositionedLevelInfoID = levelID @@ -194,6 +198,10 @@ module.exports = class WorldMapView extends RootView @openModalView authModal onSessionsLoaded: (e) -> + if @editorMode + @startedForestLevel = true + @startedDesertLevel = true + return forestLevels = (f.id for f in forest) desertLevels = (f.id for f in desert) for session in @sessions.models @@ -249,6 +257,7 @@ module.exports = class WorldMapView extends RootView onMouseEnterLevel: (e) -> return if application.isIPadApp + return if @editorMode levelID = $(e.target).parents('.level').data('level-id') return if @manuallyPositionedLevelInfoID and levelID isnt @manuallyPositionedLevelInfoID @$levelInfo = @$el.find(".level-info-container[data-level-id=#{levelID}]").show() @@ -287,8 +296,8 @@ module.exports = class WorldMapView extends RootView mapHeight = iPadHeight = 1536 mapWidth = {dungeon: 2350, forest: 2500, desert: 2350}[@terrain] or 2350 aspectRatio = mapWidth / mapHeight - pageWidth = $(window).width() - pageHeight = $(window).height() + pageWidth = @$el.width() + pageHeight = @$el.height() widthRatio = pageWidth / mapWidth heightRatio = pageHeight / mapHeight # Make sure we can see the whole map, fading to background in one dimension. diff --git a/server/campaigns/Campaign.coffee b/server/campaigns/Campaign.coffee new file mode 100644 index 000000000..b0fad1371 --- /dev/null +++ b/server/campaigns/Campaign.coffee @@ -0,0 +1,9 @@ +mongoose = require 'mongoose' +plugins = require '../plugins/plugins' + +CampaignSchema = new mongoose.Schema(body: String, {strict: false}) + +CampaignSchema.plugin(plugins.NamedPlugin) +CampaignSchema.plugin(plugins.TranslationCoveragePlugin) + +module.exports = mongoose.model('campaign', CampaignSchema) diff --git a/server/campaigns/campaign_handler.coffee b/server/campaigns/campaign_handler.coffee new file mode 100644 index 000000000..cc8217d81 --- /dev/null +++ b/server/campaigns/campaign_handler.coffee @@ -0,0 +1,70 @@ +Campaign = require './Campaign' +Level = require '../levels/Level' +Achievement = require '../achievements/Achievement' +Handler = require '../commons/Handler' +async = require 'async' +mongoose = require 'mongoose' + +CampaignHandler = class CampaignHandler extends Handler + modelClass: Campaign + editableProperties: [ + 'name' + 'i18n' + 'i18nCoverage' + 'ambientSound' + 'backgroundImage' + 'backgroundColor' + 'backgroundColorTransparent' + 'adjacentCampaigns' + 'levels' + ] + jsonSchema: require '../../app/schemas/models/campaign.schema' + + hasAccess: (req) -> + req.method is 'GET' or req.user?.isAdmin() + + getByRelationship: (req, res, args...) -> + relationship = args[1] + if relationship in ['levels', 'achievements'] + projection = {} + if req.query.project + projection[field] = 1 for field in req.query.project.split(',') + @getDocumentForIdOrSlug args[0], (err, campaign) => + return @sendDatabaseError(res, err) if err + return @sendNotFoundError(res) unless campaign? + return @getRelatedLevels(req, res, campaign, projection) if relationship is 'levels' + return @getRelatedAchievements(req, res, campaign, projection) if relationship is 'achievements' + else + super(arguments...) + + + getRelatedLevels: (req, res, campaign, projection) -> + levels = campaign.get('levels') or [] + + f = (levelOriginal) -> + (callback) -> + query = { original: mongoose.Types.ObjectId(levelOriginal) } + sort = { 'version.major': -1, 'version.minor': -1 } + Level.findOne(query, projection).sort(sort).exec callback + + fetches = (f(level.original) for level in _.values(levels)) + async.parallel fetches, (err, levels) => + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (level.toObject() for level in levels)) + + + getRelatedAchievements: (req, res, campaign, projection) -> + levels = campaign.get('levels') or [] + + f = (levelOriginal) -> + (callback) -> + query = { related: levelOriginal } + Achievement.find(query, projection).exec callback + + fetches = (f(level.original) for level in _.values(levels)) + async.parallel fetches, (err, achievementses) => + achievements = _.flatten(achievementses) + return @sendDatabaseError(res, err) if err + return @sendSuccess(res, (achievement.toObject() for achievement in achievements)) + +module.exports = new CampaignHandler() diff --git a/server/commons/Handler.coffee b/server/commons/Handler.coffee index 2b3d7b96e..21f24796e 100644 --- a/server/commons/Handler.coffee +++ b/server/commons/Handler.coffee @@ -328,7 +328,7 @@ module.exports = class Handler put: (req, res, id) -> # Client expects PATCH behavior for PUTs # Real PATCHs return incorrect HTTP responses in some environments (e.g. Browserstack, schools) - return @postNewVersion(req, res) if @modelClass.schema.uses_coco_versions + return @sendForbiddenError(res) if @modelClass.schema.uses_coco_versions and not req.user.isAdmin() return @sendBadInputError(res, 'No input.') if _.isEmpty(req.body) return @sendForbiddenError(res) unless @hasAccess(req) @getDocumentForIdOrSlug req.body._id or id, (err, document) => diff --git a/server/commons/mapping.coffee b/server/commons/mapping.coffee index 483113c7c..6ad2d1c54 100644 --- a/server/commons/mapping.coffee +++ b/server/commons/mapping.coffee @@ -3,6 +3,7 @@ module.exports.handlers = # TODO: Disabling this until we know why our app servers CPU grows out of control. # 'analytics_users_active': 'analytics/analytics_users_active_handler' 'article': 'articles/article_handler' + 'campaign': 'campaigns/campaign_handler' 'level': 'levels/level_handler' 'level_component': 'levels/components/level_component_handler' 'level_feedback': 'levels/feedbacks/level_feedback_handler' diff --git a/server/levels/level_handler.coffee b/server/levels/level_handler.coffee index a49b3852a..64cddb9cb 100644 --- a/server/levels/level_handler.coffee +++ b/server/levels/level_handler.coffee @@ -31,6 +31,26 @@ LevelHandler = class LevelHandler extends Handler 'i18nCoverage' 'loadingTip' 'requiresSubscription' + 'adventurer' + 'practice' + 'disableSpaces' + 'hidesSubmitUntilRun' + 'hidesPlayButton' + 'hidesRunShortcut' + 'hidesHUD' + 'hidesSay' + 'hidesCodeToolbar' + 'hidesRealTimePlayback' + 'backspaceThrottle' + 'lockDefaultCode' + 'moveRightLoopSnippet' + 'realTimeSpeedFactor' + 'autocompleteFontSizePx' + 'requiredCode' + 'suspectCode' + 'requiredGear' + 'restrictedGear' + 'allowedHeroes' 'tasks' ] diff --git a/test/server/common.coffee b/test/server/common.coffee index a1a894f32..ddde8dd3b 100644 --- a/test/server/common.coffee +++ b/test/server/common.coffee @@ -26,6 +26,7 @@ GLOBAL.tv4 = require 'tv4' # required for TreemaUtils to work models_path = [ '../../server/analytics/AnalyticsUsersActive' '../../server/articles/Article' + '../../server/campaigns/Campaign' '../../server/levels/Level' '../../server/levels/components/LevelComponent' '../../server/levels/systems/LevelSystem' diff --git a/test/server/functional/campaign_handler.spec.coffee b/test/server/functional/campaign_handler.spec.coffee new file mode 100644 index 000000000..cce63b7ec --- /dev/null +++ b/test/server/functional/campaign_handler.spec.coffee @@ -0,0 +1,77 @@ +require '../common' + +levels = [ + { + name: 'Level 1' + description: 'This is the first level.' + disableSpaces: true + icon: 'somestringyoudontneed.png' + } + { + name: 'Level 2' + description: 'This is the second level.' + requiresSubscription: true + backspaceThrottle: true + } +] + +achievement = { + name: 'Level 1 Complete' +} + +campaign = { + name: 'Campaign' + levels: {} +} + +levelURL = getURL('/db/level') +achievementURL = getURL('/db/achievement') +campaignURL = getURL('/db/campaign') +campaignSchema = require '../../../app/schemas/models/campaign.schema' +campaignLevelProperties = _.keys(campaignSchema.properties.levels.additionalProperties.properties) + +describe '/db/campaign', -> + it 'prepares the db first', (done) -> + clearModels [Achievement, Campaign, Level, User], (err) -> + expect(err).toBeNull() + loginAdmin (admin) -> + levels[0].permissions = levels[1].permissions = [{target: admin._id, access: 'owner'}] + request.post {uri: levelURL, json: levels[0]}, (err, res, body) -> + expect(res.statusCode).toBe(200) + levels[0] = body + request.post {uri: levelURL, json: levels[1]}, (err, res, body) -> + expect(res.statusCode).toBe(200) + levels[1] = body + achievement.related = levels[0].original + achievement.rewards = { levels: [levels[1].original] } + request.post {uri: achievementURL, json: achievement}, (err, res, body) -> + achievement = body + done() + + it 'can create campaigns', (done) -> + for level in levels.reverse() + campaign.levels[level.original] = _.pick level, campaignLevelProperties + request.post {uri: campaignURL, json: campaign}, (err, res, body) -> + expect(res.statusCode).toBe(200) + campaign = body + done() + +describe '/db/campaign/.../levels', -> + it 'fetches the levels in a campaign', (done) -> + url = getURL("/db/campaign/#{campaign._id}/levels") + request.get {uri: url}, (err, res, body) -> + expect(res.statusCode).toBe(200) + body = JSON.parse(body) + expect(body.length).toBe(2) + expect(_.difference(['level-1', 'level-2'],(level.slug for level in body)).length).toBe(0) + done() + +describe '/db/campaign/.../achievements', -> + it 'fetches the achievements in the levels in a campaign', (done) -> + url = getURL("/db/campaign/#{campaign._id}/achievements") + request.get {uri: url}, (err, res, body) -> + expect(res.statusCode).toBe(200) + body = JSON.parse(body) + expect(body.length).toBe(1) + done() + \ No newline at end of file